diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0550d8e2..687802f7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,19 +80,6 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} - - name: 🚀 Shorebird Release macOS - if: inputs.build_mac && false - uses: shorebirdtech/shorebird-release@v1 - with: - flutter-version: ${{ env.FLUTTER_VERSION }} - platform: macos - args: "--obfuscate --split-debug-info=symbols -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" - - - name: Flutter Release macOS - if: inputs.build_mac - run: - flutter build macos --release --obfuscate --split-debug-info=symbols --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }} - - name: Decode Keystore if: inputs.build_android uses: ./.github/actions/decode-keystore @@ -108,6 +95,19 @@ jobs: platform: android args: "--obfuscate --split-debug-info=symbols -- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" + - name: 🚀 Shorebird Release macOS + if: inputs.build_mac && false + uses: shorebirdtech/shorebird-release@v1 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + platform: macos + args: "--obfuscate --split-debug-info=symbols -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" + + - name: Flutter Release macOS + if: inputs.build_mac + run: + flutter build macos --release --obfuscate --split-debug-info=symbols --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }} + - name: Extract latest changelog id: changelog run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index cca557dd..e1b1f824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +### 6.2.0 (26-06-2026) +**Features**: +- Virtual front derailleur: adds a second chainring (2× drivetrain) for more realism. The new assignable "Front Shift (Chainring)" action — or pressing both shifters together, SRAM-AXS style — toggles between your small and large rings, changing resistance by the exact chainring ratio while staying on the same rear cog. While it's active your current gear reads as head-unit-style position notation (e.g. 2×14) in the app, the gear overlay, and the iOS Live Activity. Set your chainring sizes per trainer in the gear settings. +- SRAM style shifting: when you click shift up & down at the same time, the front chainring switches +- iOS / iPadOS: Floating gear overlay (Picture-in-Picture): your current gear can now show in a floating window over your trainer app during a ride. +- Screen recording: a new assignable "Record Screen" action starts a screen recording from a controller button. + ### 6.1.0 (19-06-2026) **Features**: - WiFi enabled Smart Trainers are now supported diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1e740d6d..2edc8d83 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,6 +28,10 @@ + + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d6816b41..4dd9f638 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,5 +1,9 @@ PODS: - Flutter (1.0.0) + - flutter_foreground_task (0.0.1): + - Flutter + - flutter_screen_recording (0.0.1): + - Flutter - flutter_volume_controller (0.0.1): - Flutter - gamepads_ios (0.1.1): @@ -12,22 +16,31 @@ PODS: - Flutter - restart_app (0.0.1): - Flutter + - screen_recorder (0.0.1): + - Flutter - sign_in_with_apple (0.0.1): - Flutter DEPENDENCIES: - Flutter (from `Flutter`) + - flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`) + - flutter_screen_recording (from `.symlinks/plugins/flutter_screen_recording/ios`) - flutter_volume_controller (from `.symlinks/plugins/flutter_volume_controller/ios`) - gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`) - media_key_detector_ios (from `.symlinks/plugins/media_key_detector_ios/ios`) - nsd_ios (from `.symlinks/plugins/nsd_ios/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - restart_app (from `.symlinks/plugins/restart_app/ios`) + - screen_recorder (from `.symlinks/plugins/screen_recorder/ios`) - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) EXTERNAL SOURCES: Flutter: :path: Flutter + flutter_foreground_task: + :path: ".symlinks/plugins/flutter_foreground_task/ios" + flutter_screen_recording: + :path: ".symlinks/plugins/flutter_screen_recording/ios" flutter_volume_controller: :path: ".symlinks/plugins/flutter_volume_controller/ios" gamepads_ios: @@ -40,17 +53,22 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" restart_app: :path: ".symlinks/plugins/restart_app/ios" + screen_recorder: + :path: ".symlinks/plugins/screen_recorder/ios" sign_in_with_apple: :path: ".symlinks/plugins/sign_in_with_apple/ios" SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89 + flutter_screen_recording: d27b9073d122b618a07ef0e407470d051b265b26 flutter_volume_controller: c2be490cb0487e8b88d0d9fc2b7e1c139a4ebccb gamepads_ios: c75c6d31377d275b0effb9174c619e2705678c09 media_key_detector_ios: eb43ab957d4ef9084d079216b6dbac96c91d42eb nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d restart_app: 9cda5378aacc5000e3f66ee76a9201534e7d3ecf + screen_recorder: 192d2308d545ace5676318ff0c1f2ee53231dbc1 sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 PODFILE CHECKSUM: 7ebd5c9b932b3af79d5c67e3af873118b74e970f diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 0949dd52..d522c652 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,12 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 9DEFD285994D09CFCE400F36 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE7ADD07A99710C0FB974A8 /* Pods_Runner.framework */; }; + F02276C12FEC277800C2B6F1 /* GearSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02276BF2FEC277800C2B6F1 /* GearSnapshot.swift */; }; + F02276C22FEC277800C2B6F1 /* GearReadoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02276C02FEC277800C2B6F1 /* GearReadoutView.swift */; }; + F02276C52FEC278C00C2B6F1 /* DeviceCapabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02276C32FEC278C00C2B6F1 /* DeviceCapabilities.swift */; }; + F02276C62FEC278C00C2B6F1 /* PipGearController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02276C42FEC278C00C2B6F1 /* PipGearController.swift */; }; + F02276F12FEC2ADC00C2B6F1 /* GearSnapshot+LiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02276F02FEC2ADC00C2B6F1 /* GearSnapshot+LiveActivity.swift */; }; + F02277062FEC2B6500C2B6F1 /* GearSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02276BF2FEC277800C2B6F1 /* GearSnapshot.swift */; }; F068794F2FAF1F5700395E5C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F068794E2FAF1F5700395E5C /* WidgetKit.framework */; }; F06879512FAF1F5700395E5C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F06879502FAF1F5700395E5C /* SwiftUI.framework */; }; F06879602FAF1F5900395E5C /* TrainerActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F068794D2FAF1F5700395E5C /* TrainerActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -88,6 +94,11 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DFFDC4B9C4D6EF6A3BDE2E73 /* 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 = ""; }; EFDECED99A47773C293F8819 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + F02276BF2FEC277800C2B6F1 /* GearSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = GearSnapshot.swift; path = Shared/GearSnapshot.swift; sourceTree = ""; }; + F02276C02FEC277800C2B6F1 /* GearReadoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = GearReadoutView.swift; path = Shared/GearReadoutView.swift; sourceTree = ""; }; + F02276C32FEC278C00C2B6F1 /* DeviceCapabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeviceCapabilities.swift; path = Runner/DeviceCapabilities.swift; sourceTree = ""; }; + F02276C42FEC278C00C2B6F1 /* PipGearController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PipGearController.swift; path = Runner/PipGearController.swift; sourceTree = ""; }; + F02276F02FEC2ADC00C2B6F1 /* GearSnapshot+LiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "GearSnapshot+LiveActivity.swift"; path = "Shared/GearSnapshot+LiveActivity.swift"; sourceTree = ""; }; F068794D2FAF1F5700395E5C /* TrainerActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TrainerActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F068794E2FAF1F5700395E5C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; F06879502FAF1F5700395E5C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; @@ -197,6 +208,11 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + F02276F02FEC2ADC00C2B6F1 /* GearSnapshot+LiveActivity.swift */, + F02276C32FEC278C00C2B6F1 /* DeviceCapabilities.swift */, + F02276C42FEC278C00C2B6F1 /* PipGearController.swift */, + F02276C02FEC277800C2B6F1 /* GearReadoutView.swift */, + F02276BF2FEC277800C2B6F1 /* GearSnapshot.swift */, F068796C2FAF203C00395E5C /* TrainerActivityExtension.entitlements */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, @@ -504,6 +520,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F02276C52FEC278C00C2B6F1 /* DeviceCapabilities.swift in Sources */, + F02276C62FEC278C00C2B6F1 /* PipGearController.swift in Sources */, + F02276C12FEC277800C2B6F1 /* GearSnapshot.swift in Sources */, + F02276C22FEC277800C2B6F1 /* GearReadoutView.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); @@ -513,6 +533,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F02277062FEC2B6500C2B6F1 /* GearSnapshot.swift in Sources */, + F02276F12FEC2ADC00C2B6F1 /* GearSnapshot+LiveActivity.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -804,11 +826,9 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = UZRHKPVWN9; + DEVELOPMENT_TEAM = UZRHKPVWN9; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.6; @@ -819,7 +839,6 @@ PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "BikeControl Dev"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -872,11 +891,9 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = TrainerActivityExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = UZRHKPVWN9; + DEVELOPMENT_TEAM = UZRHKPVWN9; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -897,7 +914,6 @@ PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin.TrainerActivity; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LiveActivity Debug"; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index fd74026f..3ce8f1d8 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -8,8 +8,10 @@ import UIKit /// a MethodCallHandler on this channel; native code below forwards Darwin /// notifications posted by the extension's `AppIntent`s. private static let overlayActionsChannel = "bike_control/overlay_actions_ios" + private static let pipChannelName = "bike_control/pip_ios" private var actionChannel: FlutterMethodChannel? + private var pipChannel: FlutterMethodChannel? override func application( _ application: UIApplication, @@ -39,6 +41,48 @@ import UIKit binaryMessenger: registrar.messenger() ) } + + if pipChannel == nil, + let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BikeControlPip") { + let channel = FlutterMethodChannel( + name: AppDelegate.pipChannelName, + binaryMessenger: registrar.messenger() + ) + channel.setMethodCallHandler { call, result in + switch call.method { + case "isSupported": + if #available(iOS 16.0, *) { + result(DeviceCapabilities.pipEligible) + } else { + result(false) + } + case "isCapable": + if #available(iOS 16.0, *) { + result(DeviceCapabilities.isPipCapable) + } else { + result(false) + } + case "start": + if #available(iOS 16.0, *) { + PipGearController.shared.start(initial: call.arguments as? [String: Any] ?? [:]) + } + result(nil) + case "update": + if #available(iOS 16.0, *) { + PipGearController.shared.update(call.arguments as? [String: Any] ?? [:]) + } + result(nil) + case "stop": + if #available(iOS 16.0, *) { + PipGearController.shared.stop() + } + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + pipChannel = channel + } } // MARK: - Live Activity Darwin notification bridge diff --git a/ios/Runner/DeviceCapabilities.swift b/ios/Runner/DeviceCapabilities.swift new file mode 100644 index 00000000..6e26ef5b --- /dev/null +++ b/ios/Runner/DeviceCapabilities.swift @@ -0,0 +1,51 @@ +import AVKit +import UIKit + +enum DeviceCapabilities { + /// No public API exposes "has Dynamic Island". Heuristic: Dynamic Island + /// iPhones report a larger safe-area inset (~59pt) on the cutout edge than + /// notch devices (≤48pt). iPads have no Dynamic Island. + /// + /// We use the MAX of all four insets, not just `top`: in landscape the + /// cutout moves to a side edge and the top inset collapses to ~0, so a + /// top-only check would misread a Dynamic-Island iPhone as non-DI and + /// auto-start a redundant PiP. The max inset stays orientation-independent. + static var hasDynamicIsland: Bool { + guard UIDevice.current.userInterfaceIdiom == .phone else { return false } + return keyWindowMaxInset() >= 51 + } + + /// Whether PiP is technically possible at all: iOS 16+ (ImageRenderer) and + /// the device supports PiP — regardless of the Dynamic Island. Drives the + /// opt-in toggle and honoring it on Dynamic-Island iPhones. + static var isPipCapable: Bool { + guard #available(iOS 16.0, *) else { return false } + return AVPictureInPictureController.isPictureInPictureSupported() + } + + /// PiP is the AUTOMATIC floating display on iPad and on iPhones WITHOUT a + /// Dynamic Island (Dynamic-Island iPhones default to the Live Activity, but + /// can opt into PiP via settings). + static var pipEligible: Bool { + guard isPipCapable else { return false } + if UIDevice.current.userInterfaceIdiom == .pad { return true } + return !hasDynamicIsland + } + + /// iPad has room to show the floating window immediately (foreground), rather + /// than only once BikeControl is backgrounded. + static var prefersForegroundPip: Bool { + UIDevice.current.userInterfaceIdiom == .pad + } + + private static func keyWindowMaxInset() -> CGFloat { + for scene in UIApplication.shared.connectedScenes { + guard let ws = scene as? UIWindowScene else { continue } + if let w = ws.windows.first(where: { $0.isKeyWindow }) ?? ws.windows.first { + let i = w.safeAreaInsets + return max(max(i.top, i.bottom), max(i.left, i.right)) + } + } + return 0 + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index d9a19ac9..b64d7681 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -56,6 +56,8 @@ BikeControl does not use the camera. This entry is required because the image picker plugin links camera APIs. NSPhotoLibraryUsageDescription Access your photo library to store screenshots. + NSPhotoLibraryAddUsageDescription + BikeControl saves your screen recordings to your photo library. NSSupportsLiveActivities UIApplicationSceneManifest diff --git a/ios/Runner/PipGearController.swift b/ios/Runner/PipGearController.swift new file mode 100644 index 00000000..a1658d56 --- /dev/null +++ b/ios/Runner/PipGearController.swift @@ -0,0 +1,283 @@ +import AVFoundation +import AVKit +import SwiftUI +import UIKit + +/// Renders the current-gear readout into a floating Picture-in-Picture window +/// that survives the trainer app going full-screen. Frames are drawn natively +/// from `GearReadoutView` (no Flutter render loop), so the window keeps updating +/// while BikeControl is backgrounded, riding on the active background audio +/// session that `SharedLogic.keepAlive` already maintains. +@available(iOS 16.0, *) +final class PipGearController: NSObject { + static let shared = PipGearController() + + private let displayLayer = AVSampleBufferDisplayLayer() + private var pipController: AVPictureInPictureController? + private var pump: DispatchSourceTimer? + private var hostView: UIView? + private var pool: CVPixelBufferPool? + private var ptsCount: Int64 = 0 + private var lastHash: Int? + private var snapshot: GearSnapshot? + + private let fps: Int32 = 2 + private let renderSize = CGSize(width: 480, height: 270) // 16:9 + + private override init() { super.init() } + + /// Prepare and arm PiP while the app is foreground. PiP becomes visible + /// automatically when the app is backgrounded (canStart...FromInline). + func start(initial: [String: Any]) { + guard AVPictureInPictureController.isPictureInPictureSupported() else { + NSLog("[PiP] unsupported device"); return + } + guard pipController == nil else { update(initial); return } // already armed + snapshot = GearSnapshot.fromMap(initial) + configureAudioSession() + guard attachLayer() else { NSLog("[PiP] no window to attach layer"); return } + makePool() + + displayLayer.videoGravity = .resizeAspect + let source = AVPictureInPictureController.ContentSource( + sampleBufferDisplayLayer: displayLayer, + playbackDelegate: self + ) + let controller = AVPictureInPictureController(contentSource: source) + controller.canStartPictureInPictureAutomaticallyFromInline = true + // It's a live gear HUD, not a video: disable the skip / scrub (next/prev) + // transport controls. (The central play/pause is system chrome with no + // public API to hide for sample-buffer PiP; tapping it is a harmless no-op.) + controller.requiresLinearPlayback = true + controller.delegate = self + pipController = controller + + startPump() + + // iPad: there's screen room, so show the floating window right away + // (foreground) instead of waiting for the app to be backgrounded. On + // iPhone we rely on canStartPictureInPictureAutomaticallyFromInline. + if DeviceCapabilities.prefersForegroundPip { + scheduleForegroundStart(attempt: 0) + } + } + + /// Poll until PiP becomes possible (a frame has been enqueued), then start it + /// in the foreground. Gives up after ~2.4s; background auto-start still covers + /// the normal case if foreground start never becomes possible. + private func scheduleForegroundStart(attempt: Int) { + guard attempt < 8 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + guard let self, let controller = self.pipController, + !controller.isPictureInPictureActive else { return } + if controller.isPictureInPicturePossible { + controller.startPictureInPicture() + } else { + self.scheduleForegroundStart(attempt: attempt + 1) + } + } + } + + func update(_ map: [String: Any]) { + snapshot = GearSnapshot.fromMap(map) + } + + func stop() { + pump?.cancel(); pump = nil + if pipController?.isPictureInPictureActive == true { + pipController?.stopPictureInPicture() + } + pipController = nil + displayLayer.flushAndRemoveImage() + hostView?.removeFromSuperview() + hostView = nil + pool = nil + snapshot = nil + lastHash = nil + ptsCount = 0 + } + + // MARK: - Audio session + + private func configureAudioSession() { + let session = AVAudioSession.sharedInstance() + do { + // .mixWithOthers so we never duck/stop the trainer app's audio. + try session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) + try session.setActive(true) + } catch { + NSLog("[PiP] audio session error: \(error)") + } + } + + // MARK: - Layer hosting + + private func attachLayer() -> Bool { + guard let rootView = Self.keyRootView() else { return false } + // 1×1 and behind Flutter — effectively invisible. PiP renders from the + // enqueued sample buffers, not from this inline layer's size. + let container = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) + container.isUserInteractionEnabled = false + displayLayer.frame = container.bounds + container.layer.addSublayer(displayLayer) + rootView.insertSubview(container, at: 0) + hostView = container + return true + } + + private static func keyRootView() -> UIView? { + for scene in UIApplication.shared.connectedScenes { + guard let ws = scene as? UIWindowScene else { continue } + if let w = ws.windows.first(where: { $0.isKeyWindow }) ?? ws.windows.first { + return w.rootViewController?.view + } + } + return nil + } + + // MARK: - Pixel buffer pool + + private func makePool() { + let attrs: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: Int(renderSize.width), + kCVPixelBufferHeightKey as String: Int(renderSize.height), + kCVPixelBufferCGBitmapContextCompatibilityKey as String: true, + // REQUIRED: AVSampleBufferDisplayLayer can only present IOSurface-backed + // pixel buffers. Without this the layer accepts frames (status=.rendering, + // no error) but renders nothing — only its backgroundColor shows. + kCVPixelBufferIOSurfacePropertiesKey as String: [String: Any](), + ] + let status = CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attrs as CFDictionary, &pool) + if status != kCVReturnSuccess { + NSLog("[PiP] CVPixelBufferPoolCreate failed: \(status)") + } + } + + // MARK: - Frame pump + + private func startPump() { + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now(), repeating: .milliseconds(Int(1000 / fps))) + timer.setEventHandler { [weak self] in Task { @MainActor in self?.renderTick() } } + timer.resume() + pump = timer + } + + // Runs on the main actor: `ImageRenderer` (in makePixelBuffer) is + // @MainActor-isolated. The pump's DispatchSource timer fires on `.main`, then + // hops onto the main actor via `Task { @MainActor in }` (MainActor.assumeIsolated + // would be tidier but is iOS 17+; this feature targets iOS 16). If the pump is + // ever moved off-main to survive backgrounding, keep the CGImage render on the + // main actor and enqueue the finished CMSampleBuffer off-main. + @MainActor private func renderTick() { + guard let snapshot = snapshot else { return } + let hash = snapshot.contentHash + // Skip identical frames, but always emit the first one. + if hash == lastHash, ptsCount > 0 { return } + lastHash = hash + + guard let pb = makePixelBuffer(for: snapshot), + let sample = makeSampleBuffer(from: pb) else { return } + if displayLayer.status == .failed { displayLayer.flush() } + displayLayer.enqueue(sample) + } + + @MainActor private func makePixelBuffer(for snapshot: GearSnapshot) -> CVPixelBuffer? { + guard let pool = pool else { return nil } + var pbOut: CVPixelBuffer? + guard CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pbOut) == kCVReturnSuccess, + let pb = pbOut else { return nil } + + let renderer = ImageRenderer(content: + GearReadoutView(snapshot: snapshot) + .frame(width: renderSize.width, height: renderSize.height) + ) + renderer.scale = 2.0 + guard let cgImage = renderer.cgImage else { return nil } + + CVPixelBufferLockBaseAddress(pb, []) + defer { CVPixelBufferUnlockBaseAddress(pb, []) } + guard let ctx = CGContext( + data: CVPixelBufferGetBaseAddress(pb), + width: CVPixelBufferGetWidth(pb), + height: CVPixelBufferGetHeight(pb), + bitsPerComponent: 8, + bytesPerRow: CVPixelBufferGetBytesPerRow(pb), + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue + ) else { return nil } + ctx.draw(cgImage, in: CGRect(x: 0, y: 0, + width: CVPixelBufferGetWidth(pb), + height: CVPixelBufferGetHeight(pb))) + return pb + } + + private func makeSampleBuffer(from pixelBuffer: CVPixelBuffer) -> CMSampleBuffer? { + var fmt: CMVideoFormatDescription? + guard CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &fmt + ) == noErr, let fmt = fmt else { return nil } + + let scale = CMTimeScale(fps) + var timing = CMSampleTimingInfo( + duration: CMTime(value: 1, timescale: scale), + presentationTimeStamp: CMTime(value: ptsCount, timescale: scale), + decodeTimeStamp: .invalid + ) + ptsCount += 1 + var sampleOut: CMSampleBuffer? + guard CMSampleBufferCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + dataReady: true, + makeDataReadyCallback: nil, + refcon: nil, + formatDescription: fmt, + sampleTiming: &timing, + sampleBufferOut: &sampleOut + ) == noErr, let sample = sampleOut else { return nil } + + // Present each frame the instant it's enqueued, bypassing timebase + // scheduling (we run no control timebase) — correct for a live HUD feed. + if let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: true), + CFArrayGetCount(attachments) > 0 { + let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self) + CFDictionarySetValue( + dict, + Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(), + Unmanaged.passUnretained(kCFBooleanTrue).toOpaque() + ) + } + return sample + } +} + +@available(iOS 16.0, *) +extension PipGearController: AVPictureInPictureControllerDelegate { + func pictureInPictureController(_ c: AVPictureInPictureController, + failedToStartPictureInPictureWithError error: Error) { + NSLog("[PiP] failed to start: \(error)") + } + + func pictureInPictureController( + _ c: AVPictureInPictureController, + restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void + ) { + completionHandler(true) // tapping the window brings BikeControl forward + } +} + +@available(iOS 16.0, *) +extension PipGearController: AVPictureInPictureSampleBufferPlaybackDelegate { + func pictureInPictureController(_ c: AVPictureInPictureController, setPlaying playing: Bool) {} + func pictureInPictureControllerTimeRangeForPlayback(_ c: AVPictureInPictureController) -> CMTimeRange { + CMTimeRange(start: .negativeInfinity, duration: .positiveInfinity) // live, no scrubber + } + func pictureInPictureControllerIsPlaybackPaused(_ c: AVPictureInPictureController) -> Bool { false } + func pictureInPictureController(_ c: AVPictureInPictureController, + didTransitionToRenderSize newRenderSize: CMVideoDimensions) {} + func pictureInPictureController(_ c: AVPictureInPictureController, + skipByInterval skipInterval: CMTime, + completion: @escaping () -> Void) { completion() } +} diff --git a/ios/Shared/GearReadoutView.swift b/ios/Shared/GearReadoutView.swift new file mode 100644 index 00000000..c9278c52 --- /dev/null +++ b/ios/Shared/GearReadoutView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +/// Display-only gear readout rendered into the PiP window. Visually mirrors the +/// Live Activity's non-control layout (mode pill + opted-in metrics), scaled up +/// for the small floating window. No buttons — PiP is display-only. +@available(iOS 16.0, *) +struct GearReadoutView: View { + let snapshot: GearSnapshot + + var body: some View { + VStack(spacing: 8) { + Text(snapshot.primaryText) + .font(.system(size: 84, weight: .heavy)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.4) + HStack(spacing: 12) { + Self.modePill(snapshot.mode) + if snapshot.showPower, let w = snapshot.powerW { Self.metric("\(w) W") } + if snapshot.showCadence, let rpm = snapshot.cadenceRpm { Self.metric("\(rpm) rpm") } + if !snapshot.isErg && snapshot.showGearRatio { + Self.metric(String(format: "×%.2f", snapshot.gearRatio)) + } + } + .font(.system(size: 22, weight: .semibold).monospacedDigit()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(20) + .background(Color.black) + .environment(\.colorScheme, .dark) + } + + static func modePill(_ mode: String) -> some View { + Text(mode.uppercased()) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Color.accentColor) + .clipShape(Capsule()) + } + + static func metric(_ text: String) -> some View { + Text(text).foregroundStyle(.white).lineLimit(1) + } +} diff --git a/ios/Shared/GearSnapshot+LiveActivity.swift b/ios/Shared/GearSnapshot+LiveActivity.swift new file mode 100644 index 00000000..08fdd68d --- /dev/null +++ b/ios/Shared/GearSnapshot+LiveActivity.swift @@ -0,0 +1,31 @@ +import ActivityKit +import Foundation + +@available(iOS 16.1, *) +extension GearSnapshot { + /// Read the live_activities plugin's id-prefixed App Group keys (used by the + /// Lock-Screen / Dynamic Island widget). Lives in a TrainerActivity-target-ONLY + /// file (NOT compiled into Runner) because `LiveActivitiesAppAttributes` is + /// defined in the widget extension target. Keeping it out of the shared + /// `GearSnapshot.swift` lets that file compile into Runner without ActivityKit. + static func fromLiveActivity(_ attrs: LiveActivitiesAppAttributes, defaults: UserDefaults) -> GearSnapshot { + func k(_ s: String) -> String { attrs.prefixedKey(s) } + func optInt(_ key: String) -> Int? { defaults.object(forKey: key) as? Int } + return GearSnapshot( + gear: defaults.integer(forKey: k("gear")), + maxGear: defaults.integer(forKey: k("maxGear")), + mode: defaults.string(forKey: k("mode")) ?? "sim", + powerW: optInt(k("powerW")), + cadenceRpm: optInt(k("cadenceRpm")), + ergTargetW: optInt(k("ergTargetW")), + gearRatio: defaults.double(forKey: k("gearRatio")), + showPower: defaults.bool(forKey: k("showPower")), + showCadence: defaults.bool(forKey: k("showCadence")), + showErgTarget: defaults.bool(forKey: k("showErgTarget")), + showGearRatio: defaults.bool(forKey: k("showGearRatio")), + showControls: defaults.bool(forKey: k("showControls")), + frontShiftEnabled: defaults.bool(forKey: k("frontShiftEnabled")), + frontRingLarge: defaults.bool(forKey: k("frontRingLarge")) + ) + } +} diff --git a/ios/Shared/GearSnapshot.swift b/ios/Shared/GearSnapshot.swift new file mode 100644 index 00000000..db596104 --- /dev/null +++ b/ios/Shared/GearSnapshot.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Shared gear readout model. Compiled into BOTH the Runner app (for PiP) and +/// the TrainerActivity extension (for the Live Activity), so the two surfaces +/// always agree on field semantics. +struct GearSnapshot { + let gear: Int + let maxGear: Int + let mode: String // "sim" | "erg" + let powerW: Int? + let cadenceRpm: Int? + let ergTargetW: Int? + let gearRatio: Double + let showPower: Bool + let showCadence: Bool + let showErgTarget: Bool + let showGearRatio: Bool + let showControls: Bool + let frontShiftEnabled: Bool + let frontRingLarge: Bool + + var isErg: Bool { mode == "erg" } + + /// Head-unit-style `front×rear` position notation used when the virtual + /// front derailleur is on (small ring = 1, large ring = 2, e.g. `2×14`). + private var positionGear: String { "\(frontRingLarge ? 2 : 1)×\(gear)" } + + /// Big primary value: target watts in ERG, gear in SIM. + var primaryText: String { + if isErg { return ergTargetW.map { "\($0) W" } ?? "-- W" } + if frontShiftEnabled { return positionGear } + return "\(gear)/\(maxGear)" + } + + var compactTrailing: String { + if isErg { return ergTargetW.map { "\($0)W" } ?? "--W" } + if frontShiftEnabled { return positionGear } + return "\(gear)/\(maxGear)" + } + + var minimalText: String { + if isErg { return ergTargetW.map { "\($0)" } ?? "--" } + if frontShiftEnabled { return positionGear } + return "\(gear)" + } + + /// Cheap change-detection key so the PiP pump can skip identical frames. + var contentHash: Int { + var h = Hasher() + h.combine(gear); h.combine(maxGear); h.combine(mode) + h.combine(powerW); h.combine(cadenceRpm); h.combine(ergTargetW) + h.combine(gearRatio); h.combine(showPower); h.combine(showCadence) + h.combine(showErgTarget); h.combine(showGearRatio); h.combine(showControls) + h.combine(frontShiftEnabled); h.combine(frontRingLarge) + return h.finalize() + } +} + +extension GearSnapshot { + /// Parse the map sent over `bike_control/pip_ios` — the same shape produced + /// by `overlayStateToActivityMap` on the Dart side. Missing keys fall back + /// to safe defaults so a malformed message can never crash the renderer. + static func fromMap(_ m: [String: Any]) -> GearSnapshot { + func optInt(_ key: String) -> Int? { m[key] as? Int } + return GearSnapshot( + gear: m["gear"] as? Int ?? 0, + maxGear: m["maxGear"] as? Int ?? 0, + mode: m["mode"] as? String ?? "sim", + powerW: optInt("powerW"), + cadenceRpm: optInt("cadenceRpm"), + ergTargetW: optInt("ergTargetW"), + gearRatio: m["gearRatio"] as? Double ?? 1.0, + showPower: m["showPower"] as? Bool ?? false, + showCadence: m["showCadence"] as? Bool ?? false, + showErgTarget: m["showErgTarget"] as? Bool ?? false, + showGearRatio: m["showGearRatio"] as? Bool ?? false, + showControls: m["showControls"] as? Bool ?? false, + frontShiftEnabled: m["frontShiftEnabled"] as? Bool ?? false, + frontRingLarge: m["frontRingLarge"] as? Bool ?? false + ) + } +} diff --git a/ios/TrainerActivity/TrainerActivity.swift b/ios/TrainerActivity/TrainerActivity.swift index d9c7067b..151b7ab4 100644 --- a/ios/TrainerActivity/TrainerActivity.swift +++ b/ios/TrainerActivity/TrainerActivity.swift @@ -8,57 +8,6 @@ import WidgetKit // `IosOverlayController`. let sharedDefault = UserDefaults(suiteName: "group.de.jonasbark.swiftcontrol.overlay")! -// MARK: - Snapshot - -private struct TrainerSnapshot { - let gear: Int - let maxGear: Int - let mode: String // "sim" | "erg" - let powerW: Int? - let cadenceRpm: Int? - let ergTargetW: Int? - let gearRatio: Double - let showPower: Bool - let showCadence: Bool - let showErgTarget: Bool - let showGearRatio: Bool - let showControls: Bool - - var isErg: Bool { mode == "erg" } - - /// Big primary value: target watts in ERG, gear N/M in SIM. - var primaryText: String { - if isErg { - if let w = ergTargetW { return "\(w) W" } - return "-- W" - } else { - return "\(gear) / \(maxGear)" - } - } -} - -@available(iOSApplicationExtension 16.1, *) -private func snapshot(for attrs: LiveActivitiesAppAttributes) -> TrainerSnapshot { - func k(_ s: String) -> String { attrs.prefixedKey(s) } - func optInt(_ key: String) -> Int? { - sharedDefault.object(forKey: key) as? Int - } - return TrainerSnapshot( - gear: sharedDefault.integer(forKey: k("gear")), - maxGear: sharedDefault.integer(forKey: k("maxGear")), - mode: sharedDefault.string(forKey: k("mode")) ?? "sim", - powerW: optInt(k("powerW")), - cadenceRpm: optInt(k("cadenceRpm")), - ergTargetW: optInt(k("ergTargetW")), - gearRatio: sharedDefault.double(forKey: k("gearRatio")), - showPower: sharedDefault.bool(forKey: k("showPower")), - showCadence: sharedDefault.bool(forKey: k("showCadence")), - showErgTarget: sharedDefault.bool(forKey: k("showErgTarget")), - showGearRatio: sharedDefault.bool(forKey: k("showGearRatio")), - showControls: sharedDefault.bool(forKey: k("showControls")) - ) -} - // MARK: - Bundle entry point @main @@ -80,7 +29,7 @@ struct TrainerActivity: Widget { // `.environment(\.colorScheme, .dark)` forces light-on-dark text on // every device regardless of the user's system appearance, matching // the dark `activityBackgroundTint`. - let s = snapshot(for: context.attributes) + let s = GearSnapshot.fromLiveActivity(context.attributes, defaults: sharedDefault) VStack(spacing: 4) { primaryRow(s) bottomRow(s) @@ -91,7 +40,7 @@ struct TrainerActivity: Widget { .activityBackgroundTint(Color.black.opacity(0.55)) .activitySystemActionForegroundColor(Color.white) } dynamicIsland: { context in - let s = snapshot(for: context.attributes) + let s = GearSnapshot.fromLiveActivity(context.attributes, defaults: sharedDefault) return DynamicIsland { DynamicIslandExpandedRegion(.leading) { Text(s.primaryText) @@ -116,13 +65,13 @@ struct TrainerActivity: Widget { .frame(width: 18, height: 18) .clipShape(RoundedRectangle(cornerRadius: 4)) } compactTrailing: { - Text(compactTrailing(s)) + Text(s.compactTrailing) .font(.caption2.monospacedDigit()) .foregroundStyle(.white) .lineLimit(1) .contentTransition(.numericText()) } minimal: { - Text(minimalText(s)) + Text(s.minimalText) .font(.caption2.bold()) .monospacedDigit() .foregroundStyle(.white) @@ -135,7 +84,7 @@ struct TrainerActivity: Widget { /// primary when `OverlayField.controls` is enabled (iOS 17+ only — the /// `AppIntent`-driven `Button(intent:)` initialiser requires it). @ViewBuilder - private func primaryRow(_ s: TrainerSnapshot) -> some View { + private func primaryRow(_ s: GearSnapshot) -> some View { if s.showControls, #available(iOSApplicationExtension 17.0, *) { HStack(spacing: 12) { Button(intent: ShiftPrimaryDecrementIntent()) { @@ -189,7 +138,7 @@ struct TrainerActivity: Widget { /// (The compact / minimal Dynamic Island layouts have no room for a /// secondary control.) @ViewBuilder - private func bottomRow(_ s: TrainerSnapshot) -> some View { + private func bottomRow(_ s: GearSnapshot) -> some View { HStack(spacing: 10) { modePill(s.mode) Spacer() @@ -231,18 +180,4 @@ struct TrainerActivity: Widget { .lineLimit(1) .contentTransition(.numericText()) } - - /// Compact-trailing on the Dynamic Island: very tight, ~8 chars max. - private func compactTrailing(_ s: TrainerSnapshot) -> String { - s.isErg - ? (s.ergTargetW.map { "\($0)W" } ?? "--W") - : "\(s.gear)/\(s.maxGear)" - } - - /// Minimal Dynamic Island: just the gear number or the watts target. - private func minimalText(_ s: TrainerSnapshot) -> String { - s.isErg - ? (s.ergTargetW.map { "\($0)" } ?? "--") - : "\(s.gear)" - } } diff --git a/lib/bluetooth/devices/base_device.dart b/lib/bluetooth/devices/base_device.dart index fdab05e9..41cadf34 100644 --- a/lib/bluetooth/devices/base_device.dart +++ b/lib/bluetooth/devices/base_device.dart @@ -123,6 +123,7 @@ abstract class BaseDevice { _activeLongPressButtons.clear(); } _previouslyPressedButtons = buttonsClicked.toSet(); + if (await _maybeHandleFrontShiftCombo(buttonsClicked)) return; await performClick(buttonsClicked, trigger: ButtonTrigger.singleClick); return; } @@ -484,8 +485,19 @@ abstract class BaseDevice { return []; } - Widget showInformation(BuildContext context, {required bool showFull, Widget? footer}) { + /// An optional small badge rendered immediately to the left of the Beta pill + /// in the device header. Smart trainers use it to show a transport icon when + /// the same trainer is discovered over both WiFi and Bluetooth, so the two + /// identically-named entries can be told apart. Returns null by default. + Widget? nameBadge(BuildContext context) => null; + + Widget showInformation(BuildContext context, + {required bool showFull, + Widget? footer, + bool showSettingsIcon = true, + bool showAdditionalInfo = true}) { final meta = showMetaInformation(context, showFull: showFull); + final badge = nameBadge(context); // Hero the entire header Row so the icon, title and meta fly together // when navigating between the overview's compact card and the // ControllerSettingsPage's expanded card — the same Row shape is rendered @@ -519,9 +531,10 @@ abstract class BaseDevice { toString(), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: -0.2), ), + if (badge != null) badge, if (isBeta) BetaPill(), Expanded(child: SizedBox()), - if (!showFull) + if (!showFull && showSettingsIcon) Icon( LucideIcons.settings, size: 16, @@ -545,7 +558,7 @@ abstract class BaseDevice { ), ), if (footer != null) footer, - ...showAdditionalInformation(context), + if (showAdditionalInfo) ...showAdditionalInformation(context), ], ); } @@ -574,6 +587,24 @@ abstract class BaseDevice { return button; } + /// If [buttons] resolve to exactly {shiftUp, shiftDown} and the front-shift + /// combo is enabled, emit a single frontShift and suppress the rear shifts. + Future _maybeHandleFrontShiftCombo(List buttons) async { + if (!core.actionHandler.frontShiftComboEnabled) return false; + if (buttons.length < 2) return false; + final actions = buttons + .map((b) => core.actionHandler.supportedApp?.keymap + .getKeyPair(b, trigger: ButtonTrigger.singleClick) + ?.inGameAction) + .toSet(); + if (actions.contains(InGameAction.shiftUp) && actions.contains(InGameAction.shiftDown)) { + final result = await core.actionHandler.performInGameAction(InGameAction.frontShift); + actionStreamInternal.add(ActionNotification(result)); + return true; + } + return false; + } + void _showCommandLimitAlert() { actionStreamInternal.add( AlertNotification( diff --git a/lib/bluetooth/devices/hid/hid_device.dart b/lib/bluetooth/devices/hid/hid_device.dart index 1aae640d..4eb03610 100644 --- a/lib/bluetooth/devices/hid/hid_device.dart +++ b/lib/bluetooth/devices/hid/hid_device.dart @@ -24,10 +24,19 @@ class HidDevice extends BaseDevice { } @override - Widget showInformation(BuildContext context, {required bool showFull, Widget? footer}) { + Widget showInformation(BuildContext context, + {required bool showFull, + Widget? footer, + bool showSettingsIcon = true, + bool showAdditionalInfo = true}) { return Row( children: [ - Expanded(child: super.showInformation(context, showFull: true, footer: footer)), + Expanded( + child: super.showInformation(context, + showFull: true, + footer: footer, + showSettingsIcon: showSettingsIcon, + showAdditionalInfo: showAdditionalInfo)), PopupMenuButton( itemBuilder: (c) => [ PopupMenuItem( diff --git a/lib/bluetooth/devices/mywhoosh/link.dart b/lib/bluetooth/devices/mywhoosh/link.dart index 77ad4c5f..1de380a7 100644 --- a/lib/bluetooth/devices/mywhoosh/link.dart +++ b/lib/bluetooth/devices/mywhoosh/link.dart @@ -51,6 +51,8 @@ class WhooshLink extends TrainerConnection { // blocked port must fail loudly (the caller surfaces the error). final server = ResilientTcpServer( preferredPort: 21587, + // Fixed-port contract (see above): never walk off 21587. + portAttempts: 1, onClientConnected: (socket) { if (kDebugMode) { print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}'); diff --git a/lib/bluetooth/devices/openbikecontrol/app_info_reassembler.dart b/lib/bluetooth/devices/openbikecontrol/app_info_reassembler.dart new file mode 100644 index 00000000..e9475b94 --- /dev/null +++ b/lib/bluetooth/devices/openbikecontrol/app_info_reassembler.dart @@ -0,0 +1,64 @@ +import 'dart:typed_data'; + +import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart'; +import 'package:dartx/dartx.dart'; + +/// Reassembles an OpenBikeControl app-info message that a central may split +/// across several BLE write packets (e.g. TrainingPeaks on macOS). +/// +/// Feed each write payload to [offer]. It returns the parsed [AppInfo] once the +/// accumulated buffer parses successfully, or null while the message is still +/// incomplete — in which case the fragment is retained and prepended to the +/// next write. +/// +/// Every incomplete fragment is accumulated. A single prior-fragment buffer +/// could only ever stitch TWO writes together (it kept the first failed +/// fragment and dropped the middle one), so a message split across three or +/// more packets never reassembled; this keeps all fragments until one parses. +class AppInfoReassembler { + /// Upper bound on buffered bytes. A valid app-info is ~100 bytes (32B appId + + /// 32B version + headers + a handful of button ids), so anything past this is + /// a corrupt/stuck stream — drop the stale prefix instead of poisoning every + /// future parse and growing without bound. + static const int _maxBufferedBytes = 512; + + final List _fragments = []; + + /// The error from the most recent incomplete [offer], for diagnostics/logging. + Object? lastError; + + /// Fragments currently buffered awaiting completion. + int get pendingFragments => _fragments.length; + + /// Drop any buffered fragments — call when the central disconnects so a + /// half-sent message can't bleed into the next connection. + void reset() { + _fragments.clear(); + lastError = null; + } + + /// Offer the next write payload. Returns the parsed [AppInfo] once the + /// accumulated buffer parses, or null while still incomplete. + AppInfo? offer(Uint8List value) { + try { + final appInfo = OpenBikeProtocolParser.parseAppInfo( + Uint8List.fromList([..._fragments.flatten(), ...value]), + ); + _fragments.clear(); + lastError = null; + return appInfo; + } catch (e) { + lastError = e; + _fragments.add(value); + // Bound the buffer: once the accumulation can no longer be any valid + // message, restart from this write so a stuck/corrupt stream recovers + // (and can't leak memory). + if (_fragments.fold(0, (sum, f) => sum + f.length) > _maxBufferedBytes) { + _fragments + ..clear() + ..add(value); + } + return null; + } + } +} diff --git a/lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart b/lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart index 768708bb..c549efdb 100644 --- a/lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart +++ b/lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart @@ -3,10 +3,12 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:bike_control/bluetooth/ble.dart'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/app_info_reassembler.dart'; import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart'; import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart'; import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; import 'package:bike_control/bluetooth/messages/notification.dart' show AlertNotification, LogNotification; +import 'package:bike_control/bluetooth/peripheral_advertising_recovery.dart'; import 'package:bike_control/bluetooth/peripheral_server.dart'; import 'package:bike_control/gen/l10n.dart'; import 'package:bike_control/utils/actions/base_actions.dart'; @@ -22,12 +24,16 @@ import 'package:prop/prop.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide ButtonState; import 'package:universal_ble/universal_ble.dart'; -class OpenBikeControlBluetoothEmulator extends TrainerConnection { +class OpenBikeControlBluetoothEmulator extends TrainerConnection with PeripheralAdvertisingRecovery { final _server = PeripheralServer(); final ValueNotifier connectedApp = ValueNotifier(null); bool _isServiceAdded = false; bool _isSubscribedToEvents = false; String? _currentDeviceId; + final _appInfoReassembler = AppInfoReassembler(); + + @override + PeripheralServer get advertisingServer => _server; OpenBikeControlBluetoothEmulator() : super( @@ -50,14 +56,17 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { isConnected.value = false; connectedApp.value = null; _currentDeviceId = null; + // Drop any half-received app-info so it can't poison the next central. + _appInfoReassembler.reset(); } }); - _server.onAdvertisingStateChanged((state, error) { + _server.onAdvertisingStateChanged((state, error) async { if (kDebugMode) { print('OpenBikeControl advertising state: ${state.name}${error != null ? ' — $error' : ''}'); } if (state == PeripheralAdvertisingState.error) { + if (await recoverIfAlreadyAdvertising(error)) return; core.connection.signalNotification( AlertNotification( LogLevel.LOGLEVEL_WARNING, @@ -92,7 +101,10 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { return PeripheralReadRequestResult(value: Uint8List.fromList([100])); }); - Uint8List? firstAppInfoMessage; + // Some apps (e.g. TrainingPeaks on macOS) split the app-info write + // across several BLE packets; the reassembler accumulates fragments + // until the flattened buffer parses. It's a field so disconnect can + // reset it (see onConnectionChanged) and so it outlives this closure. _server.setWriteHandler(OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID, ( deviceId, characteristicId, @@ -103,30 +115,25 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { if (kDebugMode) { print('Write request for characteristic: $characteristicId: ${bytesToReadableHex(value)}'); } - try { - // use this fallback if first message is incomplete (e.g. TrainingPeaks on macOS) - AppInfo appInfo = OpenBikeProtocolParser.parseAppInfo( - Uint8List.fromList([...?firstAppInfoMessage, ...value]), - ); - firstAppInfoMessage = null; - isConnected.value = true; - _currentDeviceId = deviceId; - connectedApp.value = appInfo; - supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList(); - final trainerApp = core.settings.getTrainerApp(); - if (trainerApp != null) { - unawaited(core.settings.setObpSupportedButtons(trainerApp.name, appInfo.supportedButtons)); - } + final appInfo = _appInfoReassembler.offer(value); + if (appInfo == null) { core.connection.signalNotification( - AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'), + LogNotification('Error parsing App Info ${bytesToHex(value)}: ${_appInfoReassembler.lastError}'), ); - core.connection.signalNotification(LogNotification('Parsed App Info: $appInfo')); - } catch (e) { - core.connection.signalNotification(LogNotification('Error parsing App Info ${bytesToHex(value)}: $e')); - if (firstAppInfoMessage == null) { - firstAppInfoMessage = value; - } + return PeripheralWriteRequestResult(); } + isConnected.value = true; + _currentDeviceId = deviceId; + connectedApp.value = appInfo; + supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList(); + final trainerApp = core.settings.getTrainerApp(); + if (trainerApp != null) { + unawaited(core.settings.setObpSupportedButtons(trainerApp.name, appInfo.supportedButtons)); + } + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'), + ); + core.connection.signalNotification(LogNotification('Parsed App Info: $appInfo')); return PeripheralWriteRequestResult(); }); } @@ -181,12 +188,18 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection { } print('Starting advertising with OpenBikeControl service...'); - await _server.startAdvertising( - services: [OpenBikeControlConstants.SERVICE_UUID], - localName: 'BikeControl', - ); + // Drop any stale/foreign advertisement (e.g. left over from a previous + // session or another peripheral role) before claiming the shared manager. + // stopAdvertising is idempotent on Darwin, so this is safe when idle. + await restartAdvertising(); } + @override + Future startServiceAdvertising() => _server.startAdvertising( + services: [OpenBikeControlConstants.SERVICE_UUID], + localName: 'BikeControl', + ); + Future stopServer() async { if (kDebugMode) { print('Stopping OpenBikeControl BLE server...'); diff --git a/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart b/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart index bc525984..ea71e20b 100644 --- a/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart +++ b/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart @@ -52,10 +52,15 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection implements OnMessage // Android's 192.0.0.8 CLAT dummy address). final localIP = await AdvertisedAddressPicker.pick(); if (localIP == null) { + isStarted.value = false; throw 'Could not find network interface'; } await _createTcpServer(); + // The port walks under contention (ResilientTcpServer's default fallback), + // so advertise the ACTUAL bound port — companion apps read it from the SRV + // record. Hardcoding 36867 here would point clients at the wrong port. + final boundPort = _server!.boundPort; try { // Create service @@ -63,7 +68,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection implements OnMessage AdvertisedService( name: 'BikeControl', type: _useDirCon ? '_wahoo-fitness-tnp._tcp' : '_openbikecontrol._tcp', - port: 36867, + port: boundPort, address: localIP, txt: _useDirCon ? { @@ -81,10 +86,13 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection implements OnMessage }, ), ); - _registeredEntry = (name: 'BikeControl', port: 36867); - SelfAdvertisementRegistry.instance.add(name: 'BikeControl', port: 36867); - print('Server started - advertising service at ${localIP.address}:36867!'); + _registeredEntry = (name: 'BikeControl', port: boundPort); + SelfAdvertisementRegistry.instance.add(name: 'BikeControl', port: boundPort); + print('Server started - advertising service at ${localIP.address}:$boundPort!'); } catch (e, s) { + // Keep the flag honest so the UI doesn't show a phantom-running server + // and the user can cleanly retry. + isStarted.value = false; core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start mDNS server: $e')); rethrow; } @@ -115,12 +123,14 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection implements OnMessage connectedApp.value = null; } - /// OpenBikeControl is a fixed-port protocol contract (36867): companion - /// apps may connect without reading the port from the advertisement, so - /// there is NO port fallback here — a blocked port must fail loudly. + /// Binds the OpenBikeControl TCP server. The preferred port is 36867 but it + /// walks to the next free port under contention (ResilientTcpServer's default + /// fallback); [startServer] advertises whichever port was actually bound, so + /// companion apps must read the port from the mDNS SRV record. Future _createTcpServer() async { final server = ResilientTcpServer( preferredPort: 36867, + label: 'OpenBikeControl', onClientConnected: (socket) { SharedLogic.keepAlive(); if (kDebugMode) { @@ -156,6 +166,9 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection implements OnMessage try { await server.start(); } catch (e) { + // A blocked fixed port (foreign holder) reaches here — reset the flag so + // the start is honestly reported as failed and can be retried. + isStarted.value = false; core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start server: $e')); rethrow; } diff --git a/lib/bluetooth/devices/proxy/proxy_device.dart b/lib/bluetooth/devices/proxy/proxy_device.dart index 8d2c9db2..cb13630d 100644 --- a/lib/bluetooth/devices/proxy/proxy_device.dart +++ b/lib/bluetooth/devices/proxy/proxy_device.dart @@ -8,6 +8,7 @@ import 'package:bike_control/gen/l10n.dart'; import 'package:bike_control/main.dart'; import 'package:bike_control/utils/actions/base_actions.dart'; import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/gear_readout.dart'; import 'package:bike_control/utils/iap/iap_manager.dart'; import 'package:bike_control/utils/keymap/apps/rouvy.dart'; import 'package:bike_control/utils/keymap/apps/supported_app.dart' show TrainerConnectionType; @@ -317,6 +318,8 @@ class ProxyDevice extends BluetoothDevice { def.setGradeSmoothingEnabled(cfg.gradeSmoothing); def.setCadenceFilterEnabled(cfg.cadenceFilterEnabled); def.setVirtualShiftingMode(cfg.mode); + def.setChainringTeeth(cfg.smallChainringTeeth, cfg.largeChainringTeeth); + def.setFrontShiftEnabled(cfg.frontShiftEnabled); if (cfg.gearRatios != null) { def.setGearRatios(cfg.gearRatios!); } @@ -453,6 +456,23 @@ class ProxyDevice extends BluetoothDevice { emulator.processCharacteristic(characteristic, bytes); } + @override + Widget? nameBadge(BuildContext context) { + // The same physical trainer can be discovered over both WiFi (DirCon) and + // Bluetooth, producing two entries with an identical name. When that + // happens, show a transport icon so the duplicates can be told apart at a + // glance — WiFi on the DirCon entry, Bluetooth on the BLE one. + final hasDuplicateName = core.connection.proxyDevices.any( + (d) => d.uniqueId != uniqueId && d.name == name, + ); + if (!hasDuplicateName) return null; + return Icon( + isWifiUpstream ? LucideIcons.wifi : LucideIcons.bluetooth, + size: 14, + color: Theme.of(context).colorScheme.mutedForeground, + ); + } + @override List showMetaInformation(BuildContext context, {required bool showFull}) { if (isConnected) { @@ -501,7 +521,7 @@ class ProxyDevice extends BluetoothDevice { _addTextMetric( parts, context, - 'Gear ${fitnessDef.currentGear.value}/${fitnessDef.maxGear}', + 'Gear ${formatGearReadout(currentGear: fitnessDef.currentGear.value, maxGear: fitnessDef.maxGear, frontShiftEnabled: fitnessDef.frontShiftEnabled, largeRing: fitnessDef.frontRing.value == FrontRing.large)}', LucideIcons.settings2, ); } @@ -639,6 +659,23 @@ class ProxyDevice extends BluetoothDevice { case InGameAction.trainerIntensityDown: def.adjustIntensity(-0.05); return Success(l10n.trainerIntensityDecreased, button: button); + case InGameAction.frontShift: + if (def.trainerMode.value == TrainerMode.ergMode) { + return Ignored(l10n.trainerFrontShiftUnavailable, button: button); + } + if (!def.frontShiftEnabled) { + return Ignored(l10n.trainerFrontShiftNotEnabled, button: button); + } + final didToggle = def.toggleFrontChainring(); + if (!didToggle) { + return Ignored(l10n.trainerFrontShiftUnavailable, button: button); + } + return Success( + def.frontRing.value == FrontRing.large + ? l10n.trainerFrontShiftedLarge + : l10n.trainerFrontShiftedSmall, + button: button, + ); default: return NotHandled('', button: button); } diff --git a/lib/bluetooth/devices/zwift/zwift_emulator.dart b/lib/bluetooth/devices/zwift/zwift_emulator.dart index e8363e76..1790b81f 100644 --- a/lib/bluetooth/devices/zwift/zwift_emulator.dart +++ b/lib/bluetooth/devices/zwift/zwift_emulator.dart @@ -8,6 +8,7 @@ import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart'; import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/bluetooth/peripheral_advertising_recovery.dart'; import 'package:bike_control/bluetooth/peripheral_server.dart'; import 'package:bike_control/gen/l10n.dart'; import 'package:bike_control/utils/actions/base_actions.dart'; @@ -25,7 +26,7 @@ import 'package:prop/prop.dart' hide RideButtonMask; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:universal_ble/universal_ble.dart'; -class ZwiftEmulator extends TrainerConnection { +class ZwiftEmulator extends TrainerConnection with PeripheralAdvertisingRecovery { bool get isLoading => _isLoading; final _server = PeripheralServer(); @@ -34,6 +35,9 @@ class ZwiftEmulator extends TrainerConnection { bool _isSubscribedToEvents = false; String? _currentDeviceId; + @override + PeripheralServer get advertisingServer => _server; + ZwiftEmulator() : super( title: () => AppLocalizations.current.connectUsingBluetooth, @@ -49,6 +53,7 @@ class ZwiftEmulator extends TrainerConnection { InGameAction.select, InGameAction.back, InGameAction.rideOnBomb, + InGameAction.frontShift, ], ); @@ -82,11 +87,12 @@ class ZwiftEmulator extends TrainerConnection { } }); - _server.onAdvertisingStateChanged((state, error) { + _server.onAdvertisingStateChanged((state, error) async { if (kDebugMode) { print('Zwift advertising state: ${state.name}${error != null ? ' — $error' : ''}'); } if (state == PeripheralAdvertisingState.error) { + if (await recoverIfAlreadyAdvertising(error)) return; core.connection.signalNotification( AlertNotification( LogLevel.LOGLEVEL_WARNING, @@ -250,15 +256,25 @@ class ZwiftEmulator extends TrainerConnection { print('Starting advertising with Zwift service...'); - await _server.startAdvertising( + await restartAdvertising(); + _isLoading = false; + onUpdate(); + } + + @override + Future startServiceAdvertising() { + final isRouvy = core.settings.getTrainerApp() is Rouvy; + return _server.startAdvertising( services: [ ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT, if (isRouvy) OpenBikeControlConstants.SERVICE_UUID, ], localName: isRouvy ? 'BikeControl' : 'KICKR BIKE PRO 1337', + // The Rouvy variant adds a 128-bit UUID which, with the name, overflows + // the 31-byte primary advertisement (Android "Data too large"); move the + // service UUIDs to the scan response there. Only Rouvy needs it. + servicesInScanResponse: isRouvy, ); - _isLoading = false; - onUpdate(); } Future stopAdvertising() async { @@ -298,6 +314,10 @@ class ZwiftEmulator extends TrainerConnection { final mapping = core.settings.getTrainerApp()?.inGameActionsMapping; var action = mapping?.entries.firstOrNullWhere((e) => e.value == keyPair.inGameAction) ?? keyPair.inGameAction; + if (action == InGameAction.frontShift) { + return _sendFrontShift(keyPair); + } + final button = switch (action) { InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN, InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN, @@ -352,6 +372,35 @@ class ZwiftEmulator extends TrainerConnection { ); } + /// Forward a front-chainring shift to the connected app as the standard + /// Zwift Ride "both shifters" gesture — the same behavior for every app + /// (apps with native SRAM, e.g. Zwift, perform the front shift; others get + /// a normal controller gesture). + Future _sendFrontShift(KeyPair keyPair) async { + // Both shift buttons pressed together. NOTE: the exact bit pair Zwift's + // SRAM detection expects is UNVERIFIED against a live Zwift session — + // confirm on-device and adjust if needed. + final combined = + RideButtonMask.SHFT_UP_R_BTN.mask | RideButtonMask.SHFT_UP_L_BTN.mask; + Logger.info('ZwiftEmulator: front-shift combo (SHFT_UP_R|SHFT_UP_L) — verify bitmask vs Zwift SRAM'); + final status = RideKeyPadStatus() + ..buttonMap = (~combined) & 0xFFFFFFFF + ..analogPaddles.clear(); + await _server.notify( + characteristicId: ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID, + value: Uint8List.fromList( + [Opcode.CONTROLLER_NOTIFICATION.value, ...status.writeToBuffer()]), + deviceId: _currentDeviceId, + ); + await _server.notify( + characteristicId: ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID, + value: Uint8List.fromList( + [Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]), + deviceId: _currentDeviceId, + ); + return Success('Sent front-shift combo', button: keyPair.buttons.firstOrNull); + } + void cleanup() { _server.stopAdvertising(); _server.clearServices(); diff --git a/lib/bluetooth/peripheral_advertising_recovery.dart b/lib/bluetooth/peripheral_advertising_recovery.dart new file mode 100644 index 00000000..12a6db49 --- /dev/null +++ b/lib/bluetooth/peripheral_advertising_recovery.dart @@ -0,0 +1,42 @@ +import 'package:bike_control/bluetooth/peripheral_server.dart'; + +/// Shared recovery for BLE peripheral emulators that advertise via a +/// [PeripheralServer]. The shared CoreBluetooth manager (or a stale +/// advertisement from a prior session) can already be advertising, making +/// CoreBluetooth reject `startAdvertising` with "Advertising has already +/// started." Mixing this in gives an emulator a one-shot stop+restart recovery +/// and a pre-emptive restart helper, without each emulator duplicating the guard. +/// +/// Implementers provide the server and their own service-advertising call +/// (which carries that emulator's services / localName / scan-response config). +mixin PeripheralAdvertisingRecovery { + PeripheralServer get advertisingServer; + + /// Start advertising THIS emulator's service(s) with its own config. + Future startServiceAdvertising(); + + bool _recoveringAdvertising = false; + + /// Drop any stale/foreign advertisement, then (re)start ours. `stopAdvertising` + /// is idempotent on Darwin, so this is safe to call when idle. + Future restartAdvertising() async { + await advertisingServer.stopAdvertising(); + await startServiceAdvertising(); + } + + /// Recover from an "Advertising has already started" error by stopping and + /// restarting our service once. Guarded so a persistent error cannot loop. + /// Returns true if it handled the error (caller should then NOT also warn). + Future recoverIfAlreadyAdvertising(String? error) async { + final alreadyStarted = error?.toLowerCase().contains('already') ?? false; + if (!alreadyStarted || _recoveringAdvertising) return false; + _recoveringAdvertising = true; + try { + await advertisingServer.stopAdvertising(); + await startServiceAdvertising(); + } finally { + _recoveringAdvertising = false; + } + return true; + } +} diff --git a/lib/bluetooth/peripheral_server.dart b/lib/bluetooth/peripheral_server.dart index 9331aeba..3bdef648 100644 --- a/lib/bluetooth/peripheral_server.dart +++ b/lib/bluetooth/peripheral_server.dart @@ -71,11 +71,18 @@ class PeripheralServer { Future startAdvertising({ required List services, String? localName, + // Put service UUIDs in the Android scan response instead of the primary + // advertisement. Needed when a 128-bit UUID + name would overflow the + // 31-byte primary packet (e.g. the Rouvy Zwift emulator advertisement). + bool servicesInScanResponse = false, }) => UniversalBlePeripheral.startAdvertising( services: services, localName: localName, platformConfig: PeripheralPlatformConfig( - android: PeripheralAndroidOptions(addManufacturerDataInScanResponse: true), + android: PeripheralAndroidOptions( + addManufacturerDataInScanResponse: true, + addServicesInScanResponse: servicesInScanResponse, + ), ), ); diff --git a/lib/i10n/intl_de.arb b/lib/i10n/intl_de.arb index 46f18bd6..05a0eca6 100644 --- a/lib/i10n/intl_de.arb +++ b/lib/i10n/intl_de.arb @@ -1,6 +1,32 @@ { + "reconnect": "Erneut verbinden", + "batterySaverTitle": "Energiesparmodus", + "controllersDisconnectedInactivity": "Controller nach {minutes} Minuten Inaktivität getrennt, um Akku zu sparen", + "@controllersDisconnectedInactivity": {"placeholders": {"minutes": {"type": "int"}}}, + "overlayDisabledIos": "Während der Fahrt schwebt das Trainer-Overlay über deiner Trainer-App – oder auf der Dynamic Island bei unterstützten iPhones – sowie auf dem Sperrbildschirm.", + "overlayUsePip": "Schwebendes Fenster (Bild-in-Bild)", + "overlayUsePipSubtitle": "Zeigt deinen Gang in einem schwebenden Fenster über deiner Trainer-App – zusätzlich zum Sperrbildschirm (und der Dynamic Island, sofern vorhanden).", + "overlayEnabled": "Overlay während der Fahrt anzeigen", + "overlayFieldCadence": "Trittfrequenz", + "overlayFieldErgTarget": "ERG-Ziel", + "overlayFieldGearRatio": "Übersetzung", + "overlayFieldPower": "Leistung", + "overlayFieldsLabel": "Anzuzeigende Felder", + "overlayGrantAndroidPermission": "Overlay-Berechtigung erteilen", + "overlayHide": "Overlay ausblenden", + "overlayLowPowerMode": "Live Activities sind deaktiviert (Energiesparmodus oder Systemeinstellung).", + "overlayPermissionExplain": "Android benötigt die Berechtigung, das Overlay über deiner Trainer-App anzuzeigen.", + "overlaySection": "Overlay", + "overlaySectionSubtitle": "Live-Ganganzeige während der Fahrt.", + "overlaySettings": "Overlay-Einstellungen", + "overlayWindowsTip": "Führe die Trainer-App im randlosen Fenstermodus aus, damit das Overlay sichtbar bleibt.", + "virtualShiftingHint": "Fügt Virtual Shifting hinzu oder passt es an und meldet BikeControl als Trainer an.", + "noConnection": "Keine Verbindung", + "noConnectionHint": "Überlasse {trainerApp} das Virtual Shifting, falls unterstützt", + "@noConnectionHint": {"placeholders": {"trainerApp": {"type": "String"}}}, "actionShiftUp": "Hochschalten", "actionShiftDown": "Runterschalten", + "actionFrontShift": "Kettenblatt wechseln", "actionUturn": "Wende", "actionTuck": "Aero-Position", "actionSteerLeft": "Nach links lenken", @@ -45,6 +71,13 @@ "actionTrainerIntensityUp": "Trainer: Intensität erhöhen", "actionTrainerIntensityDown": "Trainer: Intensität verringern", "actionWorkoutPauseResume": "Training: Pause/Fortsetzen", + "actionScreenRecording": "Bildschirm aufnehmen", + "openFolder": "Ordner öffnen", + "openGallery": "Galerie öffnen", + "screenRecordingStarted": "Bildschirmaufnahme gestartet", + "screenRecordingStopped": "Bildschirmaufnahme gespeichert", + "screenRecordingFailed": "Bildschirmaufnahme konnte nicht gestartet werden", + "screenRecordingNotSupported": "Bildschirmaufnahme wird auf diesem Gerät nicht unterstützt", "actionDFlyChannel1": "D-Fly-Kanal 1", "actionDFlyChannel2": "D-Fly-Kanal 2", "actionDFlyChannel3": "D-Fly-Kanal 3", @@ -882,6 +915,10 @@ "trainerSwitchedToErg": "In den ERG-Modus gewechselt @ {watts} W", "trainerIntensityIncreased": "Intensität +5%", "trainerIntensityDecreased": "Intensität −5%", + "trainerFrontShiftedLarge": "Vorne: großes Kettenblatt", + "trainerFrontShiftedSmall": "Vorne: kleines Kettenblatt", + "trainerFrontShiftNotEnabled": "Frontschaltung in den Gang-Einstellungen aktivieren", + "trainerFrontShiftUnavailable": "Frontschaltung nicht verfügbar", "successRatingMessage": "Danke, dass du BikeControl nutzt! Bitte überlege, BikeControl im {store} zu bewerten — das hilft uns sehr!", "rateBikeControl": "BikeControl bewerten", "bridgeMinutesRemainingToday": "Noch {minutes} Min. heute", @@ -966,5 +1003,10 @@ "vsIntroFeedbackTitle": "Wir helfen dir gerne", "vsIntroFeedbackBody": "Wir freuen uns über dein Feedback und helfen dir jederzeit, deine Einrichtung perfekt abzustimmen.", "vsIntroSupportedTrainersCta": "Unterstützte Trainer ansehen", - "vsIntroGotIt": "Verstanden" + "vsIntroGotIt": "Verstanden", + "useControllerWithApp": "{controller} mit {app} verwenden", + "frontShiftEnableLabel": "Virtueller Umwerfer", + "frontShiftEnableDesc": "Fügt ein zweites Kettenblatt hinzu (2×-Antrieb). Zum Wechseln beide Schalthebel gleichzeitig drücken – wie bei SRAM AXS.", + "frontShiftSmallRingLabel": "Kleines Kettenblatt (Zähne)", + "frontShiftLargeRingLabel": "Großes Kettenblatt (Zähne)" } diff --git a/lib/i10n/intl_en.arb b/lib/i10n/intl_en.arb index e7c872f8..eb715dfa 100644 --- a/lib/i10n/intl_en.arb +++ b/lib/i10n/intl_en.arb @@ -1,6 +1,7 @@ { "actionShiftUp": "Shift Up", "actionShiftDown": "Shift Down", + "actionFrontShift": "Front Shift (Chainring)", "actionUturn": "U-Turn", "actionTuck": "Tuck", "actionSteerLeft": "Steer Left", @@ -45,6 +46,13 @@ "actionTrainerIntensityUp": "Trainer: Intensity Up", "actionTrainerIntensityDown": "Trainer: Intensity Down", "actionWorkoutPauseResume": "Workout: Pause/Resume", + "actionScreenRecording": "Record Screen", + "openFolder": "Open folder", + "openGallery": "Open gallery", + "screenRecordingStarted": "Screen recording started", + "screenRecordingStopped": "Screen recording saved", + "screenRecordingFailed": "Could not start screen recording", + "screenRecordingNotSupported": "Screen recording isn't supported on this device", "actionDFlyChannel1": "D-Fly Channel 1", "actionDFlyChannel2": "D-Fly Channel 2", "actionDFlyChannel3": "D-Fly Channel 3", @@ -460,7 +468,9 @@ "notConnected": "Not connected", "notificationDescription": "This keeps the app alive in background and updates you when the connection to your devices changes.", "officiallySupported": "Officially supported", - "overlayDisabledIos": "Trainer overlay shows on Dynamic Island and the Lock Screen during a ride.", + "overlayDisabledIos": "During a ride the trainer overlay floats over your trainer app — or the Dynamic Island on supported iPhones — and the Lock Screen.", + "overlayUsePip": "Floating window (Picture-in-Picture)", + "overlayUsePipSubtitle": "Show your gear in a floating window over your trainer app, in addition to the Lock Screen (and the Dynamic Island where available).", "overlayEnabled": "Show overlay during ride", "overlayFieldCadence": "Cadence", "overlayFieldControls": "Shift / power buttons", @@ -1109,6 +1119,10 @@ }, "trainerIntensityIncreased": "Intensity +5%", "trainerIntensityDecreased": "Intensity -5%", + "trainerFrontShiftedLarge": "Front: large ring", + "trainerFrontShiftedSmall": "Front: small ring", + "trainerFrontShiftNotEnabled": "Enable front shift in the gear settings", + "trainerFrontShiftUnavailable": "Front shift unavailable", "successRatingMessage": "Thank you for using BikeControl! Please consider rating BikeControl in the {store} — it helps a lot!", "@successRatingMessage": {"placeholders": {"store": {"type": "String"}}}, "rateBikeControl": "Rate BikeControl", @@ -1156,5 +1170,16 @@ "vsIntroFeedbackTitle": "We've got your back", "vsIntroFeedbackBody": "We love your feedback and are always happy to help get your setup dialed in just right.", "vsIntroSupportedTrainersCta": "See supported trainers", - "vsIntroGotIt": "Got it" + "vsIntroGotIt": "Got it", + "useControllerWithApp": "Use {controller} with {app}", + "@useControllerWithApp": { + "placeholders": { + "controller": {"type": "String"}, + "app": {"type": "String"} + } + }, + "frontShiftEnableLabel": "Virtual front derailleur", + "frontShiftEnableDesc": "Adds a second chainring (2× drivetrain). Press both shifters together to change ring, like SRAM AXS.", + "frontShiftSmallRingLabel": "Small chainring (teeth)", + "frontShiftLargeRingLabel": "Large chainring (teeth)" } diff --git a/lib/i10n/intl_es.arb b/lib/i10n/intl_es.arb index 80a2d134..2ce4827a 100644 --- a/lib/i10n/intl_es.arb +++ b/lib/i10n/intl_es.arb @@ -1,6 +1,32 @@ { + "reconnect": "Reconectar", + "batterySaverTitle": "Ahorro de batería", + "controllersDisconnectedInactivity": "Mandos desconectados tras {minutes} minutos de inactividad para ahorrar batería", + "@controllersDisconnectedInactivity": {"placeholders": {"minutes": {"type": "int"}}}, + "overlayDisabledIos": "Durante la sesión, el Overlay del rodillo flota sobre tu app de entrenamiento (o en la Dynamic Island en iPhones compatibles) y en la pantalla de bloqueo.", + "overlayUsePip": "Ventana flotante (Picture-in-Picture)", + "overlayUsePipSubtitle": "Muestra tu marcha en una ventana flotante sobre tu app de entrenamiento, además de en la pantalla de bloqueo (y en la Dynamic Island cuando esté disponible).", + "overlayEnabled": "Mostrar el Overlay durante la sesión", + "overlayFieldCadence": "Cadencia", + "overlayFieldErgTarget": "Objetivo ERG", + "overlayFieldGearRatio": "Relación de marchas", + "overlayFieldPower": "Potencia", + "overlayFieldsLabel": "Campos a mostrar", + "overlayGrantAndroidPermission": "Conceder permiso de Overlay", + "overlayHide": "Ocultar el Overlay", + "overlayLowPowerMode": "Las Live Activities están desactivadas (Modo de bajo consumo o ajuste del sistema).", + "overlayPermissionExplain": "Android necesita permiso para dibujar el Overlay sobre tu app de entrenamiento.", + "overlaySection": "Overlay", + "overlaySectionSubtitle": "Visualización de marchas en directo mientras ruedas.", + "overlaySettings": "Ajustes del Overlay", + "overlayWindowsTip": "Ejecuta la app de entrenamiento en modo ventana sin bordes para que el Overlay siga visible.", + "virtualShiftingHint": "Añade o ajusta el Virtual Shifting y anuncia BikeControl como rodillo.", + "noConnection": "Sin conexión", + "noConnectionHint": "Deja que {trainerApp} gestione el Virtual Shifting, si es compatible", + "@noConnectionHint": {"placeholders": {"trainerApp": {"type": "String"}}}, "actionShiftUp": "Subir marcha", "actionShiftDown": "Bajar marcha", + "actionFrontShift": "Cambio de plato", "actionUturn": "Cambio de sentido", "actionTuck": "Posición aerodinámica", "actionSteerLeft": "Girar a la izquierda", @@ -45,6 +71,13 @@ "actionTrainerIntensityUp": "Rodillo: subir intensidad", "actionTrainerIntensityDown": "Rodillo: bajar intensidad", "actionWorkoutPauseResume": "Entrenamiento: pausar/reanudar", + "actionScreenRecording": "Grabar pantalla", + "openFolder": "Abrir carpeta", + "openGallery": "Abrir galería", + "screenRecordingStarted": "Grabación de pantalla iniciada", + "screenRecordingStopped": "Grabación de pantalla guardada", + "screenRecordingFailed": "No se pudo iniciar la grabación de pantalla", + "screenRecordingNotSupported": "La grabación de pantalla no es compatible con este dispositivo", "actionDFlyChannel1": "Canal D-Fly 1", "actionDFlyChannel2": "Canal D-Fly 2", "actionDFlyChannel3": "Canal D-Fly 3", @@ -882,6 +915,10 @@ "trainerSwitchedToErg": "Cambiado al modo ERG @ {watts} W", "trainerIntensityIncreased": "Intensidad +5%", "trainerIntensityDecreased": "Intensidad −5%", + "trainerFrontShiftedLarge": "Plato: grande", + "trainerFrontShiftedSmall": "Plato: pequeño", + "trainerFrontShiftNotEnabled": "Activa el cambio delantero en los ajustes de marcha", + "trainerFrontShiftUnavailable": "Cambio delantero no disponible", "successRatingMessage": "¡Gracias por usar BikeControl! Considera valorar BikeControl en el {store} — nos ayuda mucho.", "rateBikeControl": "Valorar BikeControl", "bridgeMinutesRemainingToday": "Quedan {minutes} min hoy", @@ -966,5 +1003,10 @@ "vsIntroFeedbackTitle": "Estamos aquí para ayudarte", "vsIntroFeedbackBody": "Nos encantan tus comentarios y siempre estaremos encantados de ayudarte a dejar tu configuración perfecta.", "vsIntroSupportedTrainersCta": "Ver Smart Trainers compatibles", - "vsIntroGotIt": "Entendido" + "vsIntroGotIt": "Entendido", + "useControllerWithApp": "Usar {controller} con {app}", + "frontShiftEnableLabel": "Desviador delantero virtual", + "frontShiftEnableDesc": "Añade un segundo plato (transmisión 2×). Pulsa ambos mandos a la vez para cambiar de plato, como en SRAM AXS.", + "frontShiftSmallRingLabel": "Plato pequeño (dientes)", + "frontShiftLargeRingLabel": "Plato grande (dientes)" } diff --git a/lib/i10n/intl_fr.arb b/lib/i10n/intl_fr.arb index b40706e6..86ad219f 100644 --- a/lib/i10n/intl_fr.arb +++ b/lib/i10n/intl_fr.arb @@ -1,6 +1,32 @@ { + "reconnect": "Reconnecter", + "batterySaverTitle": "Économiseur de batterie", + "controllersDisconnectedInactivity": "Manettes déconnectées après {minutes} minutes d'inactivité pour économiser la batterie", + "@controllersDisconnectedInactivity": {"placeholders": {"minutes": {"type": "int"}}}, + "overlayDisabledIos": "Pendant la séance, l'Overlay du home-trainer flotte au-dessus de ton app d'entraînement (ou sur la Dynamic Island sur les iPhone compatibles) et sur l'écran verrouillé.", + "overlayUsePip": "Fenêtre flottante (Picture-in-Picture)", + "overlayUsePipSubtitle": "Affiche ta vitesse dans une fenêtre flottante au-dessus de ton app d'entraînement, en plus de l'écran verrouillé (et de la Dynamic Island si disponible).", + "overlayEnabled": "Afficher l'Overlay pendant la séance", + "overlayFieldCadence": "Cadence", + "overlayFieldErgTarget": "Cible ERG", + "overlayFieldGearRatio": "Rapport de vitesses", + "overlayFieldPower": "Puissance", + "overlayFieldsLabel": "Champs à afficher", + "overlayGrantAndroidPermission": "Accorder l'autorisation d'Overlay", + "overlayHide": "Masquer l'Overlay", + "overlayLowPowerMode": "Les Live Activities sont désactivées (mode économie d'énergie ou réglage système).", + "overlayPermissionExplain": "Android a besoin d'une autorisation pour afficher l'Overlay par-dessus ton application Trainer.", + "overlaySection": "Overlay", + "overlaySectionSubtitle": "Affichage des vitesses en direct pendant que tu roules.", + "overlaySettings": "Réglages de l'Overlay", + "overlayWindowsTip": "Lance l'application Trainer en mode fenêtré sans bordure pour que l'Overlay reste visible.", + "virtualShiftingHint": "Ajoute ou ajuste le Virtual Shifting et présente BikeControl comme un home-trainer.", + "noConnection": "Aucune connexion", + "noConnectionHint": "Laisse {trainerApp} gérer le Virtual Shifting, si pris en charge", + "@noConnectionHint": {"placeholders": {"trainerApp": {"type": "String"}}}, "actionShiftUp": "Monter les vitesses", "actionShiftDown": "Descendre les vitesses", + "actionFrontShift": "Changement de plateau", "actionUturn": "Demi-tour", "actionTuck": "Position aéro", "actionSteerLeft": "Tourner à gauche", @@ -45,6 +71,13 @@ "actionTrainerIntensityUp": "Home-trainer : augmenter l’intensité", "actionTrainerIntensityDown": "Home-trainer : réduire l’intensité", "actionWorkoutPauseResume": "Séance : pause/reprendre", + "actionScreenRecording": "Enregistrer l'écran", + "openFolder": "Ouvrir le dossier", + "openGallery": "Ouvrir la galerie", + "screenRecordingStarted": "Enregistrement d'écran démarré", + "screenRecordingStopped": "Enregistrement d'écran enregistré", + "screenRecordingFailed": "Impossible de démarrer l'enregistrement d'écran", + "screenRecordingNotSupported": "L'enregistrement d'écran n'est pas pris en charge sur cet appareil", "actionDFlyChannel1": "Canal D-Fly 1", "actionDFlyChannel2": "Canal D-Fly 2", "actionDFlyChannel3": "Canal D-Fly 3", @@ -883,6 +916,10 @@ "trainerSwitchedToErg": "Basculé en mode ERG @ {watts} W", "trainerIntensityIncreased": "Intensité +5 %", "trainerIntensityDecreased": "Intensité −5 %", + "trainerFrontShiftedLarge": "Plateau : grand", + "trainerFrontShiftedSmall": "Plateau : petit", + "trainerFrontShiftNotEnabled": "Activer le changement avant dans les réglages des vitesses", + "trainerFrontShiftUnavailable": "Changement avant indisponible", "successRatingMessage": "Merci d'utiliser BikeControl ! Pense à noter BikeControl sur le {store} — ça aide beaucoup !", "rateBikeControl": "Noter BikeControl", "bridgeMinutesRemainingToday": "Encore {minutes} min aujourd'hui", @@ -967,5 +1004,10 @@ "vsIntroFeedbackTitle": "Nous sommes là pour vous", "vsIntroFeedbackBody": "Vos retours nous tiennent à cœur et nous sommes toujours ravis de vous aider à régler votre configuration aux petits oignons.", "vsIntroSupportedTrainersCta": "Voir les Smart Trainers compatibles", - "vsIntroGotIt": "J'ai compris" + "vsIntroGotIt": "J'ai compris", + "useControllerWithApp": "Utiliser {controller} avec {app}", + "frontShiftEnableLabel": "Dérailleur avant virtuel", + "frontShiftEnableDesc": "Ajoute un second plateau (transmission 2×). Appuyez sur les deux manettes en même temps pour changer de plateau, comme sur SRAM AXS.", + "frontShiftSmallRingLabel": "Petit plateau (dents)", + "frontShiftLargeRingLabel": "Grand plateau (dents)" } diff --git a/lib/i10n/intl_it.arb b/lib/i10n/intl_it.arb index c44a6e05..14c279f5 100644 --- a/lib/i10n/intl_it.arb +++ b/lib/i10n/intl_it.arb @@ -1,6 +1,32 @@ { + "reconnect": "Riconnetti", + "batterySaverTitle": "Risparmio batteria", + "controllersDisconnectedInactivity": "Controller disconnessi dopo {minutes} minuti di inattività per risparmiare batteria", + "@controllersDisconnectedInactivity": {"placeholders": {"minutes": {"type": "int"}}}, + "overlayDisabledIos": "Durante la sessione, l'Overlay del rullo galleggia sopra la tua app di allenamento (o sulla Dynamic Island sugli iPhone supportati) e sulla schermata di blocco.", + "overlayUsePip": "Finestra mobile (Picture-in-Picture)", + "overlayUsePipSubtitle": "Mostra la marcia in una finestra mobile sopra la tua app di allenamento, oltre alla schermata di blocco (e alla Dynamic Island se disponibile).", + "overlayEnabled": "Mostra l'Overlay durante la sessione", + "overlayFieldCadence": "Cadenza", + "overlayFieldErgTarget": "Obiettivo ERG", + "overlayFieldGearRatio": "Rapporto del cambio", + "overlayFieldPower": "Potenza", + "overlayFieldsLabel": "Campi da mostrare", + "overlayGrantAndroidPermission": "Concedi l'autorizzazione Overlay", + "overlayHide": "Nascondi l'Overlay", + "overlayLowPowerMode": "Le Live Activities sono disattivate (risparmio energetico o impostazione di sistema).", + "overlayPermissionExplain": "Android necessita dell'autorizzazione per mostrare l'Overlay sopra la tua app di allenamento.", + "overlaySection": "Overlay", + "overlaySectionSubtitle": "Visualizzazione delle marce in tempo reale mentre pedali.", + "overlaySettings": "Impostazioni Overlay", + "overlayWindowsTip": "Esegui l'app di allenamento in modalità finestra senza bordi affinché l'Overlay resti visibile.", + "virtualShiftingHint": "Aggiunge o regola il Virtual Shifting e annuncia BikeControl come rullo.", + "noConnection": "Nessuna connessione", + "noConnectionHint": "Lascia che {trainerApp} gestisca il Virtual Shifting, se supportato", + "@noConnectionHint": {"placeholders": {"trainerApp": {"type": "String"}}}, "actionShiftUp": "Cambio su", "actionShiftDown": "Cambio giù", + "actionFrontShift": "Cambio corona", "actionUturn": "Inversione a U", "actionTuck": "Posizione aero", "actionSteerLeft": "Sterza a sinistra", @@ -45,6 +71,13 @@ "actionTrainerIntensityUp": "Rullo: aumenta intensità", "actionTrainerIntensityDown": "Rullo: riduci intensità", "actionWorkoutPauseResume": "Allenamento: pausa/riprendi", + "actionScreenRecording": "Registra schermo", + "openFolder": "Apri cartella", + "openGallery": "Apri galleria", + "screenRecordingStarted": "Registrazione dello schermo avviata", + "screenRecordingStopped": "Registrazione dello schermo salvata", + "screenRecordingFailed": "Impossibile avviare la registrazione dello schermo", + "screenRecordingNotSupported": "La registrazione dello schermo non è supportata su questo dispositivo", "actionDFlyChannel1": "Canale D-Fly 1", "actionDFlyChannel2": "Canale D-Fly 2", "actionDFlyChannel3": "Canale D-Fly 3", @@ -882,6 +915,10 @@ "trainerSwitchedToErg": "Passato in modalità ERG @ {watts} W", "trainerIntensityIncreased": "Intensità +5%", "trainerIntensityDecreased": "Intensità −5%", + "trainerFrontShiftedLarge": "Anteriore: corona grande", + "trainerFrontShiftedSmall": "Anteriore: corona piccola", + "trainerFrontShiftNotEnabled": "Attiva il cambio anteriore nelle impostazioni dei rapporti", + "trainerFrontShiftUnavailable": "Cambio anteriore non disponibile", "successRatingMessage": "Grazie per usare BikeControl! Valuta BikeControl sull'{store} — è davvero d'aiuto.", "rateBikeControl": "Valuta BikeControl", "bridgeMinutesRemainingToday": "Ancora {minutes} min oggi", @@ -966,5 +1003,10 @@ "vsIntroFeedbackTitle": "Siamo al tuo fianco", "vsIntroFeedbackBody": "Adoriamo i tuoi feedback e siamo sempre felici di aiutarti a configurare tutto al meglio.", "vsIntroSupportedTrainersCta": "Vedi gli Smart Trainer supportati", - "vsIntroGotIt": "Ho capito" + "vsIntroGotIt": "Ho capito", + "useControllerWithApp": "Usa {controller} con {app}", + "frontShiftEnableLabel": "Deragliatore anteriore virtuale", + "frontShiftEnableDesc": "Aggiunge una seconda corona (trasmissione 2×). Premi entrambe le leve insieme per cambiare corona, come su SRAM AXS.", + "frontShiftSmallRingLabel": "Corona piccola (denti)", + "frontShiftLargeRingLabel": "Corona grande (denti)" } diff --git a/lib/i10n/intl_pl.arb b/lib/i10n/intl_pl.arb index 5652a739..8a6a067e 100644 --- a/lib/i10n/intl_pl.arb +++ b/lib/i10n/intl_pl.arb @@ -1,6 +1,32 @@ { + "reconnect": "Połącz ponownie", + "batterySaverTitle": "Oszczędzanie baterii", + "controllersDisconnectedInactivity": "Kontrolery rozłączone po {minutes} minutach bezczynności, aby oszczędzać baterię", + "@controllersDisconnectedInactivity": {"placeholders": {"minutes": {"type": "int"}}}, + "overlayDisabledIos": "Podczas jazdy Overlay trenażera unosi się nad aplikacją treningową (lub na Dynamic Island w obsługiwanych iPhone'ach) oraz na ekranie blokady.", + "overlayUsePip": "Pływające okno (Picture-in-Picture)", + "overlayUsePipSubtitle": "Pokazuje bieg w pływającym oknie nad aplikacją treningową, oprócz ekranu blokady (i Dynamic Island, jeśli jest dostępna).", + "overlayEnabled": "Pokaż Overlay podczas jazdy", + "overlayFieldCadence": "Kadencja", + "overlayFieldErgTarget": "Cel ERG", + "overlayFieldGearRatio": "Przełożenie", + "overlayFieldPower": "Moc", + "overlayFieldsLabel": "Pola do wyświetlenia", + "overlayGrantAndroidPermission": "Przyznaj uprawnienie Overlay", + "overlayHide": "Ukryj Overlay", + "overlayLowPowerMode": "Funkcje Live Activities są wyłączone (tryb niskiego zużycia energii lub ustawienie systemu).", + "overlayPermissionExplain": "Android potrzebuje uprawnienia, aby rysować Overlay nad aplikacją treningową.", + "overlaySection": "Overlay", + "overlaySectionSubtitle": "Podgląd biegów na żywo podczas jazdy.", + "overlaySettings": "Ustawienia Overlay", + "overlayWindowsTip": "Uruchom aplikację treningową w trybie okna bez obramowania, aby Overlay pozostał widoczny.", + "virtualShiftingHint": "Dodaje lub dostosowuje Virtual Shifting i ogłasza BikeControl jako trenażer.", + "noConnection": "Brak połączenia", + "noConnectionHint": "Pozwól, aby {trainerApp} obsługiwał Virtual Shifting, jeśli jest wspierany", + "@noConnectionHint": {"placeholders": {"trainerApp": {"type": "String"}}}, "actionShiftUp": "Wyższy bieg", "actionShiftDown": "Niższy bieg", + "actionFrontShift": "Zmiana tarczy", "actionUturn": "Zawracanie", "actionTuck": "Pozycja aero", "actionSteerLeft": "Skręć w lewo", @@ -45,6 +71,13 @@ "actionTrainerIntensityUp": "Trenażer: zwiększ intensywność", "actionTrainerIntensityDown": "Trenażer: zmniejsz intensywność", "actionWorkoutPauseResume": "Trening: pauza/wznów", + "actionScreenRecording": "Nagraj ekran", + "openFolder": "Otwórz folder", + "openGallery": "Otwórz galerię", + "screenRecordingStarted": "Rozpoczęto nagrywanie ekranu", + "screenRecordingStopped": "Zapisano nagranie ekranu", + "screenRecordingFailed": "Nie można rozpocząć nagrywania ekranu", + "screenRecordingNotSupported": "Nagrywanie ekranu nie jest obsługiwane na tym urządzeniu", "actionDFlyChannel1": "Kanał D-Fly 1", "actionDFlyChannel2": "Kanał D-Fly 2", "actionDFlyChannel3": "Kanał D-Fly 3", @@ -882,6 +915,10 @@ "trainerSwitchedToErg": "Przełączono na tryb ERG @ {watts} W", "trainerIntensityIncreased": "Intensywność +5%", "trainerIntensityDecreased": "Intensywność −5%", + "trainerFrontShiftedLarge": "Przód: duża tarcza", + "trainerFrontShiftedSmall": "Przód: mała tarcza", + "trainerFrontShiftNotEnabled": "Włącz zmianę z przodu w ustawieniach biegów", + "trainerFrontShiftUnavailable": "Zmiana z przodu niedostępna", "successRatingMessage": "Dzięki, że korzystasz z BikeControl! Rozważ ocenę aplikacji w {store} — bardzo nam to pomaga!", "rateBikeControl": "Oceń BikeControl", "bridgeMinutesRemainingToday": "Pozostało {minutes} min dzisiaj", @@ -966,5 +1003,10 @@ "vsIntroFeedbackTitle": "Jesteśmy do Twojej dyspozycji", "vsIntroFeedbackBody": "Uwielbiamy Twoją opinię i zawsze chętnie pomożemy idealnie skonfigurować Twój sprzęt.", "vsIntroSupportedTrainersCta": "Zobacz obsługiwane trenażery", - "vsIntroGotIt": "Rozumiem" + "vsIntroGotIt": "Rozumiem", + "useControllerWithApp": "Użyj {controller} z {app}", + "frontShiftEnableLabel": "Wirtualna przerzutka przednia", + "frontShiftEnableDesc": "Dodaje drugą tarczę (napęd 2×). Aby zmienić tarczę, naciśnij obie manetki jednocześnie – jak w SRAM AXS.", + "frontShiftSmallRingLabel": "Mała tarcza (zęby)", + "frontShiftLargeRingLabel": "Duża tarcza (zęby)" } diff --git a/lib/main.dart b/lib/main.dart index 875a7d54..ff696e9e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -73,8 +73,7 @@ Future main(List args) async { // doing any heavy bootstrap. multi_window_native re-runs main() with // kTrainerOverlayRoute as the first positional arg — this applies to // both macOS and Windows now that we use multi_window_native on both. - if (!kIsWeb && (Platform.isMacOS || Platform.isWindows) && - args.contains(kTrainerOverlayRoute)) { + if (!kIsWeb && (Platform.isMacOS || Platform.isWindows) && args.contains(kTrainerOverlayRoute)) { await wm.windowManager.ensureInitialized(); await wm.windowManager.waitUntilReadyToShow(); final windowId = await wm.windowManager.getId(); @@ -198,13 +197,19 @@ Future _persistCrash({ core.connection.signalNotification(LogNotification('App crashed $type: $error${stack != null ? '\n$stack' : ''}')); final timestamp = DateTime.now().toIso8601String(); + String debugTextValue; + try { + debugTextValue = await debugText(includeDiscovery: false); + } catch (e, s) { + debugTextValue = 'Exception $e'; + } final crashData = StringBuffer() ..writeln('--- $timestamp ---') ..writeln('Type: $type') ..writeln('Error: $error') ..writeln('Stack: ${stack ?? 'no stack'}') ..writeln('Info: ${information ?? ''}') - ..writeln(await debugText()) + ..writeln(debugTextValue) ..writeln() ..writeln(); diff --git a/lib/models/shifting_config.dart b/lib/models/shifting_config.dart index 33e4bc01..50d1c536 100644 --- a/lib/models/shifting_config.dart +++ b/lib/models/shifting_config.dart @@ -13,6 +13,10 @@ class ShiftingConfig { static const int maxGearMax = 30; static const int maxGearDefault = 24; static const int _gearRatiosMaxLength = 30; + static const int chainringTeethMin = 20; + static const int chainringTeethMax = 60; + static const int smallChainringDefault = 34; + static const int largeChainringDefault = 50; final String name; final String trainerKey; @@ -24,6 +28,9 @@ class ShiftingConfig { final bool cadenceFilterEnabled; final int maxGear; final List? gearRatios; + final bool frontShiftEnabled; + final int smallChainringTeeth; + final int largeChainringTeeth; const ShiftingConfig({ required this.name, @@ -36,6 +43,9 @@ class ShiftingConfig { this.cadenceFilterEnabled = false, this.maxGear = maxGearDefault, this.gearRatios, + this.frontShiftEnabled = false, + this.smallChainringTeeth = smallChainringDefault, + this.largeChainringTeeth = largeChainringDefault, }); factory ShiftingConfig.defaults({ @@ -83,6 +93,13 @@ class ShiftingConfig { parsedRatios.length <= _gearRatiosMaxLength) ? parsedRatios : null, + frontShiftEnabled: (json['frontShiftEnabled'] as bool?) ?? false, + smallChainringTeeth: + ((json['smallChainringTeeth'] as num?)?.toInt() ?? smallChainringDefault) + .clamp(chainringTeethMin, chainringTeethMax), + largeChainringTeeth: + ((json['largeChainringTeeth'] as num?)?.toInt() ?? largeChainringDefault) + .clamp(chainringTeethMin, chainringTeethMax), ); } @@ -97,6 +114,9 @@ class ShiftingConfig { 'cadenceFilterEnabled': cadenceFilterEnabled, 'maxGear': maxGear, if (gearRatios != null) 'gearRatios': gearRatios, + 'frontShiftEnabled': frontShiftEnabled, + 'smallChainringTeeth': smallChainringTeeth, + 'largeChainringTeeth': largeChainringTeeth, }; ShiftingConfig copyWith({ @@ -111,6 +131,9 @@ class ShiftingConfig { int? maxGear, List? gearRatios, bool clearGearRatios = false, + bool? frontShiftEnabled, + int? smallChainringTeeth, + int? largeChainringTeeth, }) { final resolvedMaxGear = maxGear ?? this.maxGear; final resolvedRatios = clearGearRatios ? null : (gearRatios ?? this.gearRatios); @@ -130,6 +153,9 @@ class ShiftingConfig { cadenceFilterEnabled: cadenceFilterEnabled ?? this.cadenceFilterEnabled, maxGear: resolvedMaxGear, gearRatios: ratiosMatchMaxGear ? resolvedRatios : null, + frontShiftEnabled: frontShiftEnabled ?? this.frontShiftEnabled, + smallChainringTeeth: smallChainringTeeth ?? this.smallChainringTeeth, + largeChainringTeeth: largeChainringTeeth ?? this.largeChainringTeeth, ); } @@ -146,7 +172,10 @@ class ShiftingConfig { gradeSmoothing == other.gradeSmoothing && cadenceFilterEnabled == other.cadenceFilterEnabled && maxGear == other.maxGear && - listEquals(gearRatios, other.gearRatios)); + listEquals(gearRatios, other.gearRatios) && + frontShiftEnabled == other.frontShiftEnabled && + smallChainringTeeth == other.smallChainringTeeth && + largeChainringTeeth == other.largeChainringTeeth); @override int get hashCode => Object.hash( @@ -160,5 +189,8 @@ class ShiftingConfig { cadenceFilterEnabled, maxGear, gearRatios == null ? null : Object.hashAll(gearRatios!), + frontShiftEnabled, + smallChainringTeeth, + largeChainringTeeth, ); } diff --git a/lib/pages/button_edit.dart b/lib/pages/button_edit.dart index 3fa124ab..af7ff3aa 100644 --- a/lib/pages/button_edit.dart +++ b/lib/pages/button_edit.dart @@ -588,30 +588,52 @@ class _ButtonEditPageState extends State { ), ], - if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isIOS)) ...[ + if (!kIsWeb) ...[ SizedBox(height: 8), ColoredTitle(text: context.i18n.otherActions), - SelectableCard( - isProOnly: true, - title: Text(Platform.isMacOS || Platform.isIOS ? 'Launch Shortcut' : 'Run Command'), - icon: Platform.isMacOS || Platform.isIOS ? Icons.rocket_launch_outlined : Icons.terminal, - isActive: _keyPair.command?.trim().isNotEmpty == true, - value: _keyPair.command, - onPressed: () async { - await _showCommandDialog(context); - }, - ), - if (Platform.isMacOS || Platform.isWindows) + if (Platform.isMacOS || Platform.isWindows || Platform.isIOS) ...[ SelectableCard( isProOnly: true, - title: Text(context.i18n.takeScreenshot), - icon: Icons.image_outlined, - isActive: _keyPair.screenshotPath?.trim().isNotEmpty == true, - value: _keyPair.screenshotPath, + title: Text(Platform.isMacOS || Platform.isIOS ? 'Launch Shortcut' : 'Run Command'), + icon: Platform.isMacOS || Platform.isIOS ? Icons.rocket_launch_outlined : Icons.terminal, + isActive: _keyPair.command?.trim().isNotEmpty == true, + value: _keyPair.command, onPressed: () async { - await _showScreenshotDialog(); + await _showCommandDialog(context); }, ), + if (Platform.isMacOS || Platform.isWindows) + SelectableCard( + isProOnly: true, + title: Text(context.i18n.takeScreenshot), + icon: Icons.image_outlined, + isActive: _keyPair.screenshotPath?.trim().isNotEmpty == true, + value: _keyPair.screenshotPath, + onPressed: () async { + await _showScreenshotDialog(); + }, + ), + ], + SelectableCard( + icon: LucideIcons.video, + isProOnly: true, + title: Text(context.i18n.actionScreenRecording), + isActive: _keyPair.inGameAction == InGameAction.screenRecording, + onPressed: () { + _keyPair.inGameAction = InGameAction.screenRecording; + _keyPair.inGameActionValue = null; + _keyPair.physicalKey = null; + _keyPair.logicalKey = null; + _keyPair.modifiers = []; + _keyPair.touchPosition = Offset.zero; + _keyPair.androidAction = null; + _keyPair.androidIntentAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + setState(() {}); + widget.onUpdate(); + }, + ), ], if (core.connection.accessories.isNotEmpty) ...[ diff --git a/lib/pages/configuration.dart b/lib/pages/configuration.dart index c5209c4f..837fd03b 100644 --- a/lib/pages/configuration.dart +++ b/lib/pages/configuration.dart @@ -42,122 +42,14 @@ class _ConfigurationPageState extends State { ColoredTitle(text: context.i18n.setupTrainer), Builder( builder: (context) { - final groupedByOfficial = SupportedApp.supportedApps.groupBy((e) => e.officialIntegration); return StatefulBuilder( builder: (c, setState) => Column( spacing: 8, mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Select( - constraints: BoxConstraints(maxWidth: 400, minWidth: 400), - popupConstraints: BoxConstraints(maxWidth: 400, minWidth: 400, minHeight: 300), - itemBuilder: (c, app) => Row( - spacing: 8, - children: [ - if (app.logoAsset != null) - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Image.asset(app.logoAsset!, width: 22, height: 22), - ), - Expanded(child: Text(screenshotMode ? 'Trainer app' : app.name)), - if (app.supports(AppConnectionMethod.obpBle) || - app.supports(AppConnectionMethod.obpMdns) || - app.supports(AppConnectionMethod.obpDirCon)) - OpenBikeControlLogo(), - ], - ), - popup: SelectPopup( - items: SelectItemList( - children: [ - if (groupedByOfficial.get(true)?.isNotEmpty == true) - Container( - color: Theme.of(context).colorScheme.accent, - padding: const EdgeInsets.all(8.0), - child: GradientText(AppLocalizations.of(context).officiallySupported).xSmall, - ), - ...groupedByOfficial.get(true)?.map((app) { - final supportsObp = - app.supports(AppConnectionMethod.obpBle) || - app.supports(AppConnectionMethod.obpMdns) || - app.supports(AppConnectionMethod.obpDirCon); - return SelectItemButton( - value: app, - child: Row( - spacing: 8, - children: [ - if (app.logoAsset != null) - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Image.asset(app.logoAsset!, width: 22, height: 22), - ), - Expanded( - child: app == core.settings.getTrainerApp() - ? Text(app.name).semiBold - : Text(app.name), - ), - if (supportsObp) OpenBikeControlLogo(), - ], - ), - ); - }), - if (groupedByOfficial.get(true)?.isNotEmpty == true) - Container( - color: Theme.of(context).colorScheme.accent, - padding: const EdgeInsets.all(8.0), - child: GradientText(AppLocalizations.of(context).otherTrainerApps).xSmall, - ), - ...groupedByOfficial.get(false)?.map((app) { - return SelectItemButton( - value: app, - child: app == core.settings.getTrainerApp() ? Text(app.name).semiBold : Text(app.name), - ); - }), - ], - ), - ).call, - placeholder: Text(context.i18n.selectTrainerAppPlaceholder), - value: core.settings.getTrainerApp(), - onChanged: (selectedApp) async { - if (selectedApp is! MyWhoosh) { - if (core.whooshLink.isStarted.value) { - core.whooshLink.stopServer(); - } - } - if (!selectedApp!.supports(AppConnectionMethod.zwiftMdns)) { - if (core.zwiftMdnsEmulator.isStarted.value) { - core.zwiftMdnsEmulator.stop(); - } - // TODO restart mDNS when advertisementName changes - } - if (!selectedApp.supports(AppConnectionMethod.zwiftBle)) { - if (core.zwiftEmulator.isStarted.value) { - core.zwiftEmulator.stopAdvertising(); - } - } - if (!selectedApp.supports(AppConnectionMethod.rouvyMdns)) { - if (core.rouvyMdnsEmulator.isStarted.value) { - core.rouvyMdnsEmulator.stop(); - } - } - if (core.obpMdnsEmulator.isStarted.value) { - core.obpMdnsEmulator.stopServer(); - } - if (core.obpBluetoothEmulator.isStarted.value) { - core.obpBluetoothEmulator.stopServer(); - } - - core.settings.setTrainerApp(selectedApp); - if (core.actionHandler.supportedApp == null || - (core.actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) { - core.actionHandler.init(selectedApp); - core.settings.setKeyMap(selectedApp); - } - core.logic.startEnabledConnectionMethod(); - - if (selectedApp is BikeControl) { - core.settings.setLastTarget(Target.thisDevice); - } + TrainerAppSelect( + onUpdate: () { widget.onUpdate(); setState(() {}); }, @@ -272,3 +164,137 @@ class _ConfigurationPageState extends State { core.logic.startEnabledConnectionMethod(); } } + +/// The trainer-app picker: a [Select] over [SupportedApp.supportedApps] that +/// reads/writes [core.settings.getTrainerApp]. Extracted from +/// [ConfigurationPage] so it can be rendered standalone (e.g. golden +/// snapshots). Behaviour is unchanged — it mutates the same `core.*` +/// singletons and notifies via [onUpdate]. +class TrainerAppSelect extends StatelessWidget { + /// Called after the trainer app changes so the host can rebuild. + final VoidCallback onUpdate; + + /// Forces the real trainer-app name to show in the closed control even when + /// [screenshotMode] is on (which otherwise replaces it with a generic + /// "Trainer app" label for the anonymized marketing screenshots). Used by the + /// MyWhoosh setup-guide snapshot, which needs the actual "MyWhoosh" name. + final bool showRealName; + const TrainerAppSelect({super.key, required this.onUpdate, this.showRealName = false}); + + @override + Widget build(BuildContext context) { + final groupedByOfficial = SupportedApp.supportedApps.groupBy((e) => e.officialIntegration); + return Select( + constraints: BoxConstraints(maxWidth: 400, minWidth: 400), + popupConstraints: BoxConstraints(maxWidth: 400, minWidth: 400, minHeight: 300), + itemBuilder: (c, app) => Row( + spacing: 8, + children: [ + if (app.logoAsset != null) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.asset(app.logoAsset!, width: 22, height: 22), + ), + Expanded(child: Text(screenshotMode && !showRealName ? 'Trainer app' : app.name)), + if (app.supports(AppConnectionMethod.obpBle) || + app.supports(AppConnectionMethod.obpMdns) || + app.supports(AppConnectionMethod.obpDirCon)) + OpenBikeControlLogo(), + ], + ), + popup: SelectPopup( + items: SelectItemList( + children: [ + if (groupedByOfficial.get(true)?.isNotEmpty == true) + Container( + color: Theme.of(context).colorScheme.accent, + padding: const EdgeInsets.all(8.0), + child: GradientText(AppLocalizations.of(context).officiallySupported).xSmall, + ), + ...groupedByOfficial.get(true)?.map((app) { + final supportsObp = + app.supports(AppConnectionMethod.obpBle) || + app.supports(AppConnectionMethod.obpMdns) || + app.supports(AppConnectionMethod.obpDirCon); + return SelectItemButton( + value: app, + child: Row( + spacing: 8, + children: [ + if (app.logoAsset != null) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.asset(app.logoAsset!, width: 22, height: 22), + ), + Expanded( + child: app == core.settings.getTrainerApp() + ? Text(app.name).semiBold + : Text(app.name), + ), + if (supportsObp) OpenBikeControlLogo(), + ], + ), + ); + }), + if (groupedByOfficial.get(true)?.isNotEmpty == true) + Container( + color: Theme.of(context).colorScheme.accent, + padding: const EdgeInsets.all(8.0), + child: GradientText(AppLocalizations.of(context).otherTrainerApps).xSmall, + ), + ...groupedByOfficial.get(false)?.map((app) { + return SelectItemButton( + value: app, + child: app == core.settings.getTrainerApp() ? Text(app.name).semiBold : Text(app.name), + ); + }), + ], + ), + ).call, + placeholder: Text(context.i18n.selectTrainerAppPlaceholder), + value: core.settings.getTrainerApp(), + onChanged: (selectedApp) async { + if (selectedApp is! MyWhoosh) { + if (core.whooshLink.isStarted.value) { + core.whooshLink.stopServer(); + } + } + if (!selectedApp!.supports(AppConnectionMethod.zwiftMdns)) { + if (core.zwiftMdnsEmulator.isStarted.value) { + core.zwiftMdnsEmulator.stop(); + } + // TODO restart mDNS when advertisementName changes + } + if (!selectedApp.supports(AppConnectionMethod.zwiftBle)) { + if (core.zwiftEmulator.isStarted.value) { + core.zwiftEmulator.stopAdvertising(); + } + } + if (!selectedApp.supports(AppConnectionMethod.rouvyMdns)) { + if (core.rouvyMdnsEmulator.isStarted.value) { + core.rouvyMdnsEmulator.stop(); + } + } + if (core.obpMdnsEmulator.isStarted.value) { + core.obpMdnsEmulator.stopServer(); + } + if (core.obpBluetoothEmulator.isStarted.value) { + core.obpBluetoothEmulator.stopServer(); + } + + core.settings.setTrainerApp(selectedApp); + if (core.actionHandler.supportedApp == null || + (core.actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) { + core.actionHandler.init(selectedApp); + core.settings.setKeyMap(selectedApp); + } + core.logic.startEnabledConnectionMethod(); + + if (selectedApp is BikeControl) { + core.settings.setLastTarget(Target.thisDevice); + } + onUpdate(); + }, + ); + } +} diff --git a/lib/pages/controller_settings.dart b/lib/pages/controller_settings.dart index 4d651d4d..410f1299 100644 --- a/lib/pages/controller_settings.dart +++ b/lib/pages/controller_settings.dart @@ -2,6 +2,7 @@ import 'package:bike_control/bluetooth/devices/base_device.dart'; import 'package:bike_control/gen/l10n.dart'; import 'package:bike_control/pages/customize.dart'; import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/help_article.dart'; import 'package:bike_control/utils/iap/iap_manager.dart'; import 'package:bike_control/utils/keymap/keymap.dart'; import 'package:bike_control/widgets/device_script_drawer.dart'; @@ -10,6 +11,7 @@ import 'package:bike_control/widgets/ui/pro_badge.dart'; import 'package:bike_control/widgets/ui/small_progress_indicator.dart'; import 'package:bike_control/widgets/ui/trainer_label.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:url_launcher/url_launcher_string.dart'; class ControllerSettingsPage extends StatefulWidget { final BaseDevice device; @@ -26,6 +28,7 @@ class _ControllerSettingsPageState extends State { final device = widget.device; final trainerApp = core.settings.getTrainerApp(); final keymap = core.actionHandler.supportedApp?.keymap; + final helpArticle = helpArticleFor(context, controller: device, app: trainerApp); return Scaffold( headers: [ @@ -61,6 +64,17 @@ class _ControllerSettingsPageState extends State { children: [ // Device card _buildDeviceCard(device), + + // How-to-connect guide for this controller + the selected app + if (helpArticle != null) ...[ + const Gap(12), + _buildActionButton( + icon: LucideIcons.bookOpen, + label: helpArticle.label, + onTap: () => launchUrlString(helpArticle.url), + trailing: Icon(LucideIcons.externalLink, size: 16), + ), + ], const Gap(24), // Button mapping diff --git a/lib/pages/device.dart b/lib/pages/device.dart index f8efcdbb..d7d4fd1a 100644 --- a/lib/pages/device.dart +++ b/lib/pages/device.dart @@ -125,20 +125,36 @@ class _DevicePageState extends State { if (group.length == 1) _buildDeviceCard(group.single) else - IntrinsicHeight( - child: Row( - spacing: 12, - crossAxisAlignment: CrossAxisAlignment.start, - children: group - .map((device) => Expanded(child: _buildDeviceCard(device))) - .joinSeparator( - VerticalDivider( - thickness: Theme.of(context).brightness == Brightness.dark ? 1 : 0.5, - endIndent: 12, + // A full-height VerticalDivider can't be used here: it demands + // infinite height inside this scrollable Column, and wrapping in + // IntrinsicHeight doesn't help because the card's ControllerCanvas + // footer (LayoutBuilder/AspectRatio) can't answer intrinsic-height + // queries. Draw the separator as a trailing border instead — no + // intrinsics, no infinite height. + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (i, device) in group.indexed) + Expanded( + child: Container( + decoration: i < group.length - 1 + ? BoxDecoration( + border: Border( + right: BorderSide( + color: Theme.of(context).colorScheme.border, + width: Theme.of(context).brightness == Brightness.dark ? 1 : 0.5, + ), + ), + ) + : null, + padding: EdgeInsets.only( + left: i > 0 ? 12 : 0, + right: i < group.length - 1 ? 12 : 0, ), - ) - .toList(), - ), + child: _buildDeviceCard(device), + ), + ), + ], ), if (index != deviceGroups.length - 1) ...[ Divider( diff --git a/lib/pages/overview.dart b/lib/pages/overview.dart index 11346e26..ecc9fa8a 100644 --- a/lib/pages/overview.dart +++ b/lib/pages/overview.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math'; import 'package:bike_control/bluetooth/devices/base_device.dart'; @@ -10,6 +11,7 @@ import 'package:bike_control/pages/proxy.dart'; import 'package:bike_control/pages/subscription.dart'; import 'package:bike_control/pages/trainer_connection_settings.dart'; import 'package:bike_control/services/blog_service.dart'; +import 'package:bike_control/services/screen_recording/screen_recording_service.dart'; import 'package:bike_control/utils/actions/base_actions.dart'; import 'package:bike_control/utils/core.dart'; import 'package:bike_control/utils/i18n_extension.dart'; @@ -31,6 +33,7 @@ import 'package:bike_control/widgets/ui/connection_method.dart' show ConnectionM import 'package:bike_control/widgets/ui/toast.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:gal/gal.dart'; import 'package:prop/prop.dart' show LogLevel, Logger, RetrofitMode; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:universal_ble/universal_ble.dart'; @@ -228,7 +231,20 @@ class _OverviewPageState extends State with TickerProviderStateMix } void _onActionResult(ActionResult result, ControllerButton button) { - final entry = _ActivityEntry(button: button, time: DateTime.now(), result: result); + // A saved screen recording gets a "reveal" action on its activity entry: + // open the containing folder on desktop, or the gallery on mobile. + final savedPath = result is Success ? result.filePath : null; + final hasRecording = savedPath != null && savedPath.isNotEmpty && !kIsWeb; + final isDesktop = !kIsWeb && (Platform.isMacOS || Platform.isWindows); + final entry = _ActivityEntry( + button: button, + time: DateTime.now(), + result: result, + buttonTitle: hasRecording + ? (isDesktop ? AppLocalizations.of(context).openFolder : AppLocalizations.of(context).openGallery) + : null, + onTap: hasRecording ? () => _openRecordingLocation(savedPath) : null, + ); _insertActivityEntry(entry); if (entry.isError) { @@ -263,6 +279,22 @@ class _OverviewPageState extends State with TickerProviderStateMix } } + /// Reveals a saved recording: the containing folder in Finder / Explorer on + /// desktop, or the system gallery on mobile (where it was saved via `gal`). + Future _openRecordingLocation(String filePath) async { + try { + if (Platform.isMacOS) { + await Process.run('open', [File(filePath).parent.path]); + } else if (Platform.isWindows) { + await Process.run('explorer', [File(filePath).parent.path]); + } else { + await Gal.open(); + } + } catch (e, s) { + recordError(e, s, context: 'open recording location'); + } + } + void _onAlert(AlertNotification notification) { final isInForeground = navigatorKey.currentState?.canPop() == false; @@ -350,6 +382,20 @@ class _OverviewPageState extends State with TickerProviderStateMix Expanded( child: _buildSectionHeader(icon: Icons.gamepad, title: AppLocalizations.of(context).controllers), ), + ValueListenableBuilder( + valueListenable: core.screenRecording.state, + builder: (context, state, _) { + if (state != ScreenRecordingState.recording) return const SizedBox.shrink(); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.fiber_manual_record, color: Colors.red, size: 12), + const SizedBox(width: 4), + Text(context.i18n.screenRecordingStarted).xSmall.muted, + ], + ); + }, + ), if (core.settings.getIgnoredDevices().isNotEmpty) Button.text( style: ButtonStyle.menu(), diff --git a/lib/pages/proxy_device_details.dart b/lib/pages/proxy_device_details.dart index 3f956dd9..65a4352c 100644 --- a/lib/pages/proxy_device_details.dart +++ b/lib/pages/proxy_device_details.dart @@ -16,6 +16,7 @@ import 'package:bike_control/pages/proxy_device_details/virtual_shifting_pro_not import 'package:bike_control/pages/support_chat/support_chat_page.dart'; import 'package:bike_control/services/overview_screenshot.dart'; import 'package:bike_control/services/telemetry_snapshot.dart'; +import 'package:bike_control/widgets/menu.dart' show debugText; import 'package:bike_control/utils/core.dart'; import 'package:bike_control/utils/i18n_extension.dart'; import 'package:bike_control/utils/iap/iap_manager.dart'; @@ -217,19 +218,26 @@ class _ProxyDeviceDetailsPageState extends State { Future _submitFeedback(String key, String label) async { final device = widget.device; - final base = buildProxyServicesFreetext(device); - final composed = (base == null || base.isEmpty) ? key : '$key\n\n$base'; await core.settings.setFeedbackSubmitted(device.trainerKey, true); if (!mounted) return; setState(() {}); - final snapshot = TelemetrySnapshot.fromDevice(device: device, freetextOverride: composed); final screenshot = await captureOverviewScreenshot(context: context); if (!mounted) return; + // Build telemetry in the background so the chat opens immediately. The full + // debugText (gathered here) already carries this trainer's services & + // characteristics, the diagnostics block and the log buffer, so we attach it + // instead of just the services snippet. The page awaits this future lazily + // for the diagnostic preview and at send time. + final snapshotFuture = () async { + final debug = await debugText(); + return TelemetrySnapshot.fromDevice(device: device, freetextOverride: '$key\n$debug'); + }(); await Navigator.of(context).push( MaterialPageRoute( builder: (_) => SupportChatPage( - telemetryBuilder: () async => snapshot, - diagnosticPreview: JsonEncoder.withIndent(' ').convert(snapshot.toJson()), + telemetryBuilder: () => snapshotFuture, + diagnosticPreviewFuture: + snapshotFuture.then((s) => JsonEncoder.withIndent(' ').convert(s.toJson())), initialText: '$label\n', initialAttachment: screenshot, ), diff --git a/lib/pages/proxy_device_details/front_shift_card.dart b/lib/pages/proxy_device_details/front_shift_card.dart new file mode 100644 index 00000000..5c9bc2a8 --- /dev/null +++ b/lib/pages/proxy_device_details/front_shift_card.dart @@ -0,0 +1,106 @@ +import 'package:bike_control/bluetooth/devices/proxy/proxy_device.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/models/shifting_config.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/widgets/ui/setting_tile.dart'; +import 'package:bike_control/widgets/ui/stepper_control.dart'; +import 'package:prop/emulators/definitions/fitness_bike_definition.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// The "Virtual front derailleur" setting: an enable toggle plus small/large +/// chainring steppers and the resulting ratio factor. Persists to the active +/// [ShiftingConfig] for [device] AND applies to the live [definition] so the +/// change takes effect immediately (mirroring the other cards on this page — +/// otherwise the gear settings only apply after a reconnect/mode-switch). +class FrontShiftCard extends StatelessWidget { + const FrontShiftCard({super.key, required this.device, required this.definition}); + + final ProxyDevice device; + final FitnessBikeDefinition definition; + + Future _update(ShiftingConfig Function(ShiftingConfig) mutate) async { + final current = core.shiftingConfigs.activeFor(device.trainerKey); + await core.shiftingConfigs.upsert(mutate(current)); + } + + @override + Widget build(BuildContext context) { + final config = core.shiftingConfigs.activeFor(device.trainerKey); + final enabled = config.frontShiftEnabled; + final small = config.smallChainringTeeth; + final large = config.largeChainringTeeth; + final factor = large / small; + final cs = Theme.of(context).colorScheme; + return SettingTile( + icon: LucideIcons.bike, + title: AppLocalizations.of(context).frontShiftEnableLabel, + subtitle: AppLocalizations.of(context).frontShiftEnableDesc, + trailing: Switch( + value: enabled, + onChanged: (v) async { + definition.setFrontShiftEnabled(v); + await _update((c) => c.copyWith(frontShiftEnabled: v)); + }, + ), + child: enabled + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 12, + children: [ + Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context).frontShiftSmallRingLabel, + style: const TextStyle(fontSize: 13), + ), + ), + StepperControl( + value: small.toDouble(), + step: 1.0, + min: ShiftingConfig.chainringTeethMin.toDouble(), + // Keep small <= large: the large ring must stay the bigger + // (harder) one, else the front shift inverts or no-ops. + max: large.toDouble(), + format: (v) => v.toStringAsFixed(0), + onChanged: (v) async { + final next = v.toInt(); + definition.setChainringTeeth(next, large); + await _update((c) => c.copyWith(smallChainringTeeth: next)); + }, + ), + ], + ), + Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context).frontShiftLargeRingLabel, + style: const TextStyle(fontSize: 13), + ), + ), + StepperControl( + value: large.toDouble(), + step: 1.0, + min: small.toDouble(), + max: ShiftingConfig.chainringTeethMax.toDouble(), + format: (v) => v.toStringAsFixed(0), + onChanged: (v) async { + final next = v.toInt(); + definition.setChainringTeeth(small, next); + await _update((c) => c.copyWith(largeChainringTeeth: next)); + }, + ), + ], + ), + Text( + '${factor.toStringAsFixed(2)}×', + style: TextStyle(fontSize: 12, color: cs.mutedForeground), + textAlign: TextAlign.end, + ), + ], + ) + : null, + ); + } +} diff --git a/lib/pages/proxy_device_details/gear_hero_card.dart b/lib/pages/proxy_device_details/gear_hero_card.dart index 9c21dff2..af1c2b5b 100644 --- a/lib/pages/proxy_device_details/gear_hero_card.dart +++ b/lib/pages/proxy_device_details/gear_hero_card.dart @@ -47,6 +47,7 @@ class _GearHeroCardState extends State { widget.definition.targetPowerW, widget.definition.currentGear, widget.definition.gearRatio, + widget.definition.frontRing, ]), builder: (context, _) { final isErg = widget.definition.trainerMode.value == TrainerMode.ergMode; @@ -172,6 +173,13 @@ class _GearHeroCardState extends State { color: cs.mutedForeground, ), ), + if (widget.definition.frontShiftEnabled) + Text( + widget.definition.frontRing.value == FrontRing.large + ? '2× · ${widget.definition.largeChainringTeeth}T' + : '1× · ${widget.definition.smallChainringTeeth}T', + style: TextStyle(fontSize: 13, color: cs.mutedForeground), + ), ], ); } diff --git a/lib/pages/proxy_device_details/gear_ratios_editor_page.dart b/lib/pages/proxy_device_details/gear_ratios_editor_page.dart index 49b1e105..ca0b5368 100644 --- a/lib/pages/proxy_device_details/gear_ratios_editor_page.dart +++ b/lib/pages/proxy_device_details/gear_ratios_editor_page.dart @@ -4,6 +4,7 @@ import 'package:bike_control/bluetooth/devices/proxy/proxy_device.dart'; import 'package:bike_control/gen/l10n.dart'; import 'package:bike_control/main.dart'; import 'package:bike_control/models/shifting_config.dart'; +import 'package:bike_control/pages/proxy_device_details/front_shift_card.dart'; import 'package:bike_control/pages/proxy_device_details/gear_hero_card.dart'; import 'package:bike_control/pages/proxy_device_details/gear_ratio_curve.dart'; import 'package:bike_control/utils/core.dart'; @@ -113,6 +114,7 @@ class _GearRatiosEditorPageState extends State { _vsModeCard(), _gradeSmoothingCard(context), _cadenceFilterCard(context), + FrontShiftCard(device: widget.device, definition: def), ], _gearCountCard(context), _heroCurve(context), diff --git a/lib/pages/proxy_device_details/overlay_settings_section.dart b/lib/pages/proxy_device_details/overlay_settings_section.dart index 1d1fee94..6e251f5f 100644 --- a/lib/pages/proxy_device_details/overlay_settings_section.dart +++ b/lib/pages/proxy_device_details/overlay_settings_section.dart @@ -2,6 +2,7 @@ import 'dart:io' show Platform; import 'package:bike_control/bluetooth/devices/proxy/proxy_device.dart'; import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/services/overlay/ios_pip_controller.dart'; import 'package:bike_control/services/overlay/overlay_state.dart'; import 'package:bike_control/services/overlay/trainer_overlay_controller.dart'; import 'package:bike_control/services/overlay/trainer_overlay_service.dart'; @@ -30,6 +31,9 @@ class _OverlaySettingsSectionState extends State { late bool _enabled; late Set _fields; bool _androidPermissionGranted = false; + bool _pipCapable = false; + bool _pipAutoDefault = false; + bool? _pipPref; @override void initState() { @@ -41,6 +45,35 @@ class _OverlaySettingsSectionState extends State { _fields = core.settings.getOverlayFields(); _controller.isShowing.addListener(_syncFromController); _refreshAndroidPermission(); + _pipPref = core.settings.getOverlayUsePip(); + _loadPipCapability(); + } + + Future _loadPipCapability() async { + if (kIsWeb || !Platform.isIOS) return; + final pip = IosPipController(); + final capable = await pip.isCapable(); + final auto = await pip.isSupported(); + if (!mounted) return; + setState(() { + _pipCapable = capable; + _pipAutoDefault = auto; + }); + } + + Future _togglePip(bool v) async { + await core.settings.setOverlayUsePip(v); + if (mounted) setState(() => _pipPref = v); + // Re-apply immediately if the overlay is already showing. + if (_enabled) { + await _controller.hide(); + final res = await _controller.show( + widget.definition, + _fields, + liveDef: () => widget.device.fitnessBike, + ); + if (mounted) setState(() => _enabled = res.ok); + } } Future _refreshAndroidPermission() async { @@ -125,6 +158,13 @@ class _OverlaySettingsSectionState extends State { trailing: Switch(value: _enabled, onChanged: _toggle), child: _enabled ? _fieldsCard(l10n) : null, ), + if (isIos && _pipCapable) + SettingTile( + icon: LucideIcons.appWindow, + title: l10n.overlayUsePip, + subtitle: l10n.overlayUsePipSubtitle, + trailing: Switch(value: _pipPref ?? _pipAutoDefault, onChanged: _togglePip), + ), if (!kIsWeb && Platform.isWindows && _enabled) _tipCard(l10n.overlayWindowsTip), if (isAndroid && !_androidPermissionGranted) _androidPermissionTile(l10n), ], diff --git a/lib/pages/support_chat/support_chat_page.dart b/lib/pages/support_chat/support_chat_page.dart index be5da8df..0d90baad 100644 --- a/lib/pages/support_chat/support_chat_page.dart +++ b/lib/pages/support_chat/support_chat_page.dart @@ -23,7 +23,11 @@ typedef TelemetryBuilder = Future Function(); class SupportChatPage extends StatefulWidget { final TelemetryBuilder telemetryBuilder; - final String? diagnosticPreview; + + /// Resolves to the diagnostic-payload preview shown in the composer. Awaited + /// lazily (in [initState]) so opening the chat is instant while the + /// diagnostics gather in the background. + final Future? diagnosticPreviewFuture; final String? initialText; /// Optional attachment to pre-stage in the composer on first build @@ -34,7 +38,7 @@ class SupportChatPage extends StatefulWidget { const SupportChatPage({ super.key, required this.telemetryBuilder, - this.diagnosticPreview, + this.diagnosticPreviewFuture, this.initialText, this.initialAttachment, }); @@ -57,6 +61,7 @@ class _SupportChatPageState extends State with WidgetsBindingOb IntakeAnswers? _intakeAnswers; bool _intakeSent = false; bool _editingIntake = false; + String? _diagnosticPreview; @override void initState() { @@ -73,6 +78,9 @@ class _SupportChatPageState extends State with WidgetsBindingOb _bootstrap(); } _loadIssues(); + widget.diagnosticPreviewFuture?.then((preview) { + if (mounted) setState(() => _diagnosticPreview = preview); + }); } Future _loadIssues() async { @@ -289,7 +297,7 @@ class _SupportChatPageState extends State with WidgetsBindingOb SupportComposer( sending: _sending, onSend: _send, - diagnosticPreview: widget.diagnosticPreview, + diagnosticPreview: _diagnosticPreview, initialText: widget.initialText, initialAttachment: widget.initialAttachment, ), diff --git a/lib/services/debug_diagnostics.dart b/lib/services/debug_diagnostics.dart new file mode 100644 index 00000000..6432c4a7 --- /dev/null +++ b/lib/services/debug_diagnostics.dart @@ -0,0 +1,191 @@ +import 'dart:io' show Platform; + +import 'package:bike_control/main.dart' show recordError; +import 'package:bike_control/services/mdns_discovery_scan.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/mdns/service_advertiser.dart'; +import 'package:prop/utils/advertised_service_registry.dart'; +import 'package:prop/utils/network_address.dart'; +import 'package:prop/utils/resilient_tcp_server.dart'; + +/// A running TCP bridge server, for diagnostics. +class TcpServerInfo { + final String? label; + final int? port; + final bool listening; + final bool hasClient; + + const TcpServerInfo({ + required this.label, + required this.port, + required this.listening, + required this.hasClient, + }); +} + +/// Status of the permissions whose denial silently breaks WiFi/BLE bridging. +class PermissionsSnapshot { + /// iOS Local Network can't be queried directly; inferred from whether a + /// discovery scan returned anything. Null when no scan ran. + final bool? localNetworkInferred; + + const PermissionsSnapshot({ + required this.localNetworkInferred, + }); + + static Future gather({bool? localNetworkInferred}) async { + return PermissionsSnapshot( + localNetworkInferred: localNetworkInferred, + ); + } +} + +/// The full diagnostics snapshot shown on the Logs page and embedded in +/// [debugText]. Build with [gather]; render with [toText]. +class DebugDiagnostics { + final List advertised; + final String backend; + final String? hostLabel; + final bool holdsMulticastLock; + final List discovered; + final bool discoveryRan; + final AddressPickReport addressReport; + final List servers; + final PermissionsSnapshot permissions; + + const DebugDiagnostics({ + required this.advertised, + required this.backend, + required this.hostLabel, + required this.holdsMulticastLock, + required this.discovered, + required this.discoveryRan, + required this.addressReport, + required this.servers, + required this.permissions, + }); + + static Future gather({ + bool includeDiscovery = true, + Duration discoveryTimeout = const Duration(seconds: 4), + }) async { + final advertiser = ServiceAdvertiser.instance; + final isResponder = advertiser is ResponderServiceAdvertiser; + + AddressPickReport addressReport; + try { + addressReport = await AdvertisedAddressPicker.report(); + } catch (e, s) { + recordError(e, s, context: 'DebugDiagnostics.address'); + addressReport = const AddressPickReport(chosen: null, candidates: []); + } + + final servers = ResilientTcpServer.activeServers + .map( + (s) => TcpServerInfo( + label: s.label, + port: s.isRunning ? s.boundPort : null, + listening: s.isRunning, + hasClient: s.hasClient, + ), + ) + .toList(); + + var discovered = []; + var discoveryRan = false; + if (includeDiscovery && !kIsWeb) { + try { + discovered = await MdnsDiscoveryScan().run(timeout: discoveryTimeout); + discoveryRan = true; + } catch (e, s) { + recordError(e, s, context: 'DebugDiagnostics.discovery'); + } + } + + final permissions = await PermissionsSnapshot.gather( + // iOS is the only platform with a (non-queryable) "Local Network" + // permission; infer it from whether discovery saw anything. Elsewhere an + // empty scan just means no peers, so leave it unset. + localNetworkInferred: (discoveryRan && !kIsWeb && Platform.isIOS) ? discovered.isNotEmpty : null, + ); + + return DebugDiagnostics( + advertised: AdvertisedServiceRegistry.instance.records, + backend: isResponder ? 'responder' : 'nsd', + hostLabel: isResponder ? advertiser.hostLabel : null, + holdsMulticastLock: isResponder ? advertiser.holdsMulticastLock : false, + discovered: discovered, + discoveryRan: discoveryRan, + addressReport: addressReport, + servers: servers, + permissions: permissions, + ); + } + + String _txt(Map txt) { + final entries = txt.entries.toList()..sort((a, b) => a.key.compareTo(b.key)); + return entries.map((e) => '${e.key}=${e.value}').join(', '); + } + + String toText() { + final b = StringBuffer(); + b.writeln('Diagnostics:'); + + b.writeln(' Advertised by this device:'); + if (advertised.isEmpty) { + b.writeln(' (none)'); + } else { + for (final a in advertised) { + b.writeln(' ${a.type} "${a.name}" ${a.address}:${a.port}'); + if (a.txt.isNotEmpty) b.writeln(' txt: ${_txt(a.txt)}'); + } + } + b.writeln( + ' backend: $backend' + '${hostLabel != null ? ' · host: $hostLabel.local' : ''}' + '${backend == 'responder' ? ' · multicast-lock: ${holdsMulticastLock ? 'held' : 'not held'}' : ''}', + ); + + b.writeln(' Discovered on network:'); + if (!discoveryRan) { + b.writeln(' (skipped)'); + } else if (discovered.isEmpty) { + b.writeln(' (none found)'); + } else { + for (final d in discovered) { + b.writeln(' ${d.type} "${d.name}" ${d.host}:${d.port}${d.isSelf ? ' (this device)' : ''}'); + if (d.txt.isNotEmpty) b.writeln(' txt: ${_txt(d.txt)}'); + } + } + + b.writeln(' Network interfaces (advertised = ${addressReport.chosen?.address ?? 'none'}):'); + for (final c in addressReport.candidates) { + final tags = [ + if (addressReport.chosen?.address == c.address) 'advertised', + if (c.isVirtual) 'virtual', + ]; + b.writeln(' ${c.interfaceName}/${c.address} = ${c.score}${tags.isEmpty ? '' : ' (${tags.join(', ')})'}'); + } + + b.writeln(' TCP servers:'); + if (servers.isEmpty) { + b.writeln(' (none)'); + } else { + for (final s in servers) { + b.writeln( + ' ${s.label ?? 'tcp'} :${s.port ?? '-'} ' + '${s.listening ? 'listening' : 'down'} · ${s.hasClient ? '1 client' : 'no client'}', + ); + } + } + + if (permissions.localNetworkInferred != null) { + b.writeln( + ' Permissions: ios-local-network=' + '${permissions.localNetworkInferred! ? 'inferred-ok' : 'inferred-blocked'}', + ); + } + + return b.toString().trimRight(); + } +} diff --git a/lib/services/mdns_discovery_scan.dart b/lib/services/mdns_discovery_scan.dart new file mode 100644 index 00000000..ed3c06cc --- /dev/null +++ b/lib/services/mdns_discovery_scan.dart @@ -0,0 +1,116 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bike_control/bluetooth/wifi_trainer_scanner.dart'; +import 'package:bike_control/main.dart' show recordError; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:nsd/nsd.dart' as nsd; +import 'package:prop/utils/advertised_service_registry.dart'; + +/// One mDNS service seen on the LAN during a diagnostics scan. +class DiscoveredMdnsService { + final String type; + final String name; + final String host; + final int port; + final Map txt; + + /// True when this is one of BikeControl's own advertisements echoed back. + final bool isSelf; + + const DiscoveredMdnsService({ + required this.type, + required this.name, + required this.host, + required this.port, + required this.txt, + required this.isSelf, + }); + + factory DiscoveredMdnsService.fromService( + nsd.Service service, { + required String host, + required int port, + required bool isSelf, + }) { + final txt = {}; + service.txt?.forEach((k, v) { + txt[k] = v == null ? '' : decodeMdnsTxt(v); + }); + return DiscoveredMdnsService( + type: service.type ?? '', + name: service.name ?? '', + host: host, + port: port, + txt: txt, + isSelf: isSelf, + ); + } +} + +/// Time-boxed browse of the OpenBikeControl and Wahoo DirCon service types. +/// Includes our own advertisement (flagged [DiscoveredMdnsService.isSelf]) so +/// the user can confirm the responder is reachable over the wire. +class MdnsDiscoveryScan { + static const types = ['_openbikecontrol._tcp', '_wahoo-fitness-tnp._tcp']; + + Future> run({ + Duration timeout = const Duration(seconds: 4), + }) async { + if (kIsWeb) return []; + final localAddresses = await _listLocalAddresses(); + nsd.disableServiceTypeValidation(true); + + final found = {}; + void listener(nsd.Service service, nsd.ServiceStatus status) { + if (status == nsd.ServiceStatus.lost) return; + final name = service.name; + final port = service.port; + final host = service.addresses + ?.firstOrNullWhere((a) => a.type == InternetAddressType.IPv4) + ?.address ?? + service.host; + if (name == null || port == null || host == null) return; + final isSelf = + WifiTrainerScanner.isSelfAdvertisement(service, localAddresses: localAddresses); + found['${service.type}/$name'] = DiscoveredMdnsService.fromService( + service, + host: host, + port: port, + isSelf: isSelf, + ); + } + + final discoveries = []; + try { + for (final type in types) { + final discovery = await nsd.startDiscovery(type, autoResolve: true); + discovery.addServiceListener(listener); + discoveries.add(discovery); + } + await Future.delayed(timeout); + } finally { + for (final discovery in discoveries) { + discovery.removeServiceListener(listener); + try { + await nsd.stopDiscovery(discovery); + } catch (e, s) { + recordError(e, s, context: 'MdnsDiscoveryScan.stop'); + } + } + } + return found.values.toList(); + } + + Future> _listLocalAddresses() async { + try { + final interfaces = + await NetworkInterface.list(includeLinkLocal: true, type: InternetAddressType.any); + return interfaces.expand((i) => i.addresses).map((a) => a.address).toSet(); + } catch (e, s) { + recordError(e, s, context: 'MdnsDiscoveryScan.localAddresses'); + return {}; + } + } +} diff --git a/lib/services/overlay/android_overlay_controller.dart b/lib/services/overlay/android_overlay_controller.dart index 5d14243a..65856119 100644 --- a/lib/services/overlay/android_overlay_controller.dart +++ b/lib/services/overlay/android_overlay_controller.dart @@ -236,6 +236,8 @@ class AndroidOverlayController implements TrainerOverlayController { cadenceRpm: def.cadenceRpm.value, ergTargetW: def.ergTargetPower.value, fields: _fields, + frontShiftEnabled: def.frontShiftEnabled, + frontRingLarge: def.frontRing.value == FrontRing.large, ); if (!force && s == _lastPushed) return; _lastPushed = s; diff --git a/lib/services/overlay/desktop_overlay_controller.dart b/lib/services/overlay/desktop_overlay_controller.dart index 8abb409a..5ecc9074 100644 --- a/lib/services/overlay/desktop_overlay_controller.dart +++ b/lib/services/overlay/desktop_overlay_controller.dart @@ -316,6 +316,8 @@ class DesktopOverlayController implements TrainerOverlayController { cadenceRpm: def.cadenceRpm.value, ergTargetW: def.ergTargetPower.value, fields: _fields, + frontShiftEnabled: def.frontShiftEnabled, + frontRingLarge: def.frontRing.value == FrontRing.large, ); if (!force && s == _lastPushed) return; _lastPushed = s; diff --git a/lib/services/overlay/ios_overlay_controller.dart b/lib/services/overlay/ios_overlay_controller.dart index 18bc2d98..7493a160 100644 --- a/lib/services/overlay/ios_overlay_controller.dart +++ b/lib/services/overlay/ios_overlay_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'package:bike_control/main.dart'; +import 'package:bike_control/services/overlay/ios_pip_controller.dart'; import 'package:bike_control/services/overlay/overlay_state.dart'; import 'package:bike_control/services/overlay/trainer_overlay_controller.dart'; import 'package:bike_control/utils/core.dart'; @@ -23,6 +24,8 @@ class IosOverlayController implements TrainerOverlayController { final ValueNotifier _showing = ValueNotifier(false); final LiveActivities _la = LiveActivities(); String? _activityId; + final IosPipController _pip = IosPipController(); + bool _pipActive = false; FitnessBikeDefinition? _def; LiveDefinitionLookup? _liveDef; @@ -79,6 +82,26 @@ class IosOverlayController implements TrainerOverlayController { } _showing.value = true; + try { + // null pref = automatic device default (iPad / non-Dynamic-Island iPhones); + // an explicit true opts in everywhere PiP is capable (e.g. DI iPhones), an + // explicit false opts out. The Live Activity keeps running either way. + final pref = core.settings.getOverlayUsePip(); + final usePip = pref == null ? await _pip.isSupported() : (pref && await _pip.isCapable()); + // hide() may have run during the awaits above (Live Activity 'stop', + // trainer disconnect, …) and set _showing=false; only arm PiP if we're + // still showing, and tear it back down if a hide lands during start(). + if (usePip && _showing.value) { + await _pip.start(_toMap(s)); + _pipActive = true; + if (!_showing.value) { + _pipActive = false; + await _pip.stop(); + } + } + } catch (e, st) { + recordError(e, st, context: 'overlay.ios.pip.start'); + } return const OverlayShowResult.ok(); } @@ -101,6 +124,14 @@ class IosOverlayController implements TrainerOverlayController { _activityId = null; } _showing.value = false; + if (_pipActive) { + try { + await _pip.stop(); + } catch (e, s) { + recordError(e, s, context: 'overlay.ios.pip.stop'); + } + _pipActive = false; + } } @override @@ -150,30 +181,12 @@ class IosOverlayController implements TrainerOverlayController { cadenceRpm: def.cadenceRpm.value, ergTargetW: def.ergTargetPower.value, fields: _fields, + frontShiftEnabled: def.frontShiftEnabled, + frontRingLarge: def.frontRing.value == FrontRing.large, ); } - // live_activities routes the map through NSUserDefaults in the App Group, - // which crashes on null values (NSInvalidArgumentException). Optional Swift - // fields (Int?) decode missing keys as nil via Codable, so omitting nulls - // yields the same ContentState. - Map _toMap(TrainerOverlayState s) { - final m = { - 'gear': s.gear, - 'maxGear': s.maxGear, - 'mode': s.mode == TrainerMode.ergMode ? 'erg' : 'sim', - 'showPower': s.fields.contains(OverlayField.power), - 'showCadence': s.fields.contains(OverlayField.cadence), - 'showErgTarget': s.fields.contains(OverlayField.ergTarget), - 'showGearRatio': s.fields.contains(OverlayField.gearRatio), - 'showControls': s.fields.contains(OverlayField.controls), - 'gearRatio': s.gearRatio, - }; - if (s.powerW != null) m['powerW'] = s.powerW; - if (s.cadenceRpm != null) m['cadenceRpm'] = s.cadenceRpm; - if (s.ergTargetW != null) m['ergTargetW'] = s.ergTargetW; - return m; - } + Map _toMap(TrainerOverlayState s) => overlayStateToActivityMap(s); Future _push({bool force = false}) async { final id = _activityId; @@ -187,6 +200,9 @@ class IosOverlayController implements TrainerOverlayController { _lastPushAt = DateTime.now(); try { await _la.updateActivity(id, _toMap(s)); + if (_pipActive) { + await _pip.update(_toMap(s)); + } } catch (error, stack) { recordError(error, stack, context: 'overlay.ios.updateActivity'); } diff --git a/lib/services/overlay/ios_pip_controller.dart b/lib/services/overlay/ios_pip_controller.dart new file mode 100644 index 00000000..e4211dae --- /dev/null +++ b/lib/services/overlay/ios_pip_controller.dart @@ -0,0 +1,57 @@ +import 'package:bike_control/main.dart'; +import 'package:flutter/services.dart'; + +/// Thin Dart wrapper over the native `bike_control/pip_ios` channel. PiP runs +/// in the app process, so gear state is pushed straight over the channel into +/// `PipGearController`'s memory — no App Group UserDefaults round-trip. +class IosPipController { + static const _channel = MethodChannel('bike_control/pip_ios'); + + /// Automatic default: true on iPad and non-Dynamic-Island iPhones (iOS 16+). + /// False on Dynamic-Island iPhones (they default to the Live Activity) and + /// where PiP is unsupported. + Future isSupported() async { + try { + return await _channel.invokeMethod('isSupported') ?? false; + } catch (e, s) { + recordError(e, s, context: 'pip.ios.isSupported'); + return false; + } + } + + /// Whether PiP is technically possible at all (iOS 16+ and device supports it), + /// regardless of the Dynamic Island. Used to honor the opt-in on DI iPhones and + /// to decide whether to show the setting. + Future isCapable() async { + try { + return await _channel.invokeMethod('isCapable') ?? false; + } catch (e, s) { + recordError(e, s, context: 'pip.ios.isCapable'); + return false; + } + } + + Future start(Map state) async { + try { + await _channel.invokeMethod('start', state); + } catch (e, s) { + recordError(e, s, context: 'pip.ios.start'); + } + } + + Future update(Map state) async { + try { + await _channel.invokeMethod('update', state); + } catch (e, s) { + recordError(e, s, context: 'pip.ios.update'); + } + } + + Future stop() async { + try { + await _channel.invokeMethod('stop'); + } catch (e, s) { + recordError(e, s, context: 'pip.ios.stop'); + } + } +} diff --git a/lib/services/overlay/overlay_state.dart b/lib/services/overlay/overlay_state.dart index 3b28d607..d62a085d 100644 --- a/lib/services/overlay/overlay_state.dart +++ b/lib/services/overlay/overlay_state.dart @@ -28,6 +28,8 @@ class TrainerOverlayState { final int? cadenceRpm; final int? ergTargetW; final Set fields; + final bool frontShiftEnabled; + final bool frontRingLarge; const TrainerOverlayState({ required this.gear, @@ -38,6 +40,8 @@ class TrainerOverlayState { required this.cadenceRpm, required this.ergTargetW, required this.fields, + this.frontShiftEnabled = false, + this.frontRingLarge = false, }); Map toJson() => { @@ -49,6 +53,8 @@ class TrainerOverlayState { 'cadenceRpm': cadenceRpm, 'ergTargetW': ergTargetW, 'fields': fields.map((f) => f.name).toList(), + 'frontShiftEnabled': frontShiftEnabled, + 'frontRingLarge': frontRingLarge, }; /// Permissive parse — silently fills missing/wrong-typed fields with sane @@ -77,6 +83,8 @@ class TrainerOverlayState { cadenceRpm: (json['cadenceRpm'] as num?)?.toInt(), ergTargetW: (json['ergTargetW'] as num?)?.toInt(), fields: fields, + frontShiftEnabled: (json['frontShiftEnabled'] as bool?) ?? false, + frontRingLarge: (json['frontRingLarge'] as bool?) ?? false, ); } @@ -91,13 +99,15 @@ class TrainerOverlayState { other.powerW == powerW && other.cadenceRpm == cadenceRpm && other.ergTargetW == ergTargetW && + other.frontShiftEnabled == frontShiftEnabled && + other.frontRingLarge == frontRingLarge && _setEquals(other.fields, fields); } @override int get hashCode => Object.hash( gear, maxGear, gearRatio, mode, powerW, cadenceRpm, ergTargetW, - Object.hashAllUnordered(fields), + Object.hashAllUnordered(fields), frontShiftEnabled, frontRingLarge, ); static bool _setEquals(Set a, Set b) { @@ -108,3 +118,28 @@ class TrainerOverlayState { return true; } } + +/// Serialize overlay state to the map shape consumed by the iOS Live Activity +/// (via the live_activities plugin's App Group UserDefaults) AND by the PiP +/// channel. Omits null optional metrics: the Live Activity path routes through +/// NSUserDefaults which crashes on null values, and Swift's optional `Int?` +/// fields decode missing keys as nil either way. +Map overlayStateToActivityMap(TrainerOverlayState s) { + final m = { + 'gear': s.gear, + 'maxGear': s.maxGear, + 'mode': s.mode == TrainerMode.ergMode ? 'erg' : 'sim', + 'showPower': s.fields.contains(OverlayField.power), + 'showCadence': s.fields.contains(OverlayField.cadence), + 'showErgTarget': s.fields.contains(OverlayField.ergTarget), + 'showGearRatio': s.fields.contains(OverlayField.gearRatio), + 'showControls': s.fields.contains(OverlayField.controls), + 'gearRatio': s.gearRatio, + 'frontShiftEnabled': s.frontShiftEnabled, + 'frontRingLarge': s.frontRingLarge, + }; + if (s.powerW != null) m['powerW'] = s.powerW; + if (s.cadenceRpm != null) m['cadenceRpm'] = s.cadenceRpm; + if (s.ergTargetW != null) m['ergTargetW'] = s.ergTargetW; + return m; +} diff --git a/lib/services/screen_recording/backends/android_screen_recorder.dart b/lib/services/screen_recording/backends/android_screen_recorder.dart new file mode 100644 index 00000000..bc7a67e9 --- /dev/null +++ b/lib/services/screen_recording/backends/android_screen_recorder.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:bike_control/services/screen_recording/screen_recording_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_screen_recording/flutter_screen_recording.dart'; +import 'package:gal/gal.dart'; + +/// Android backend: system-wide MediaProjection capture via flutter_screen_recording, +/// then the resulting mp4 is copied into the device gallery via `gal`. +class AndroidScreenRecorder implements ScreenRecorderBackend { + @override + Future isAvailable() async => true; + + @override + Future ensurePermission() async { + // MediaProjection consent is requested by startRecordScreen itself. + // Gallery save needs gallery access on older Androids; request up front. + try { + if (await Gal.hasAccess(toAlbum: true)) return true; + return await Gal.requestAccess(toAlbum: true); + } catch (e, s) { + debugPrintStack(label: 'gal access: $e', stackTrace: s); + return true; // don't block recording if the gallery check itself failed + } + } + + @override + Future start() async { + try { + // Video only — use startRecordScreen (no audio variant). + final name = 'BikeControl_${DateTime.now().millisecondsSinceEpoch}'; + return await FlutterScreenRecording.startRecordScreen(name); + } catch (e, s) { + debugPrintStack(label: 'android startRecordScreen: $e', stackTrace: s); + return false; + } + } + + @override + Future stop() async { + try { + final path = await FlutterScreenRecording.stopRecordScreen; + if (path.isEmpty || !File(path).existsSync()) return null; + await Gal.putVideo(path, album: 'BikeControl'); + return path; + } catch (e, s) { + debugPrintStack(label: 'android stopRecordScreen/gal: $e', stackTrace: s); + return null; + } + } +} diff --git a/lib/services/screen_recording/backends/native_channel_screen_recorder.dart b/lib/services/screen_recording/backends/native_channel_screen_recorder.dart new file mode 100644 index 00000000..ef50ac57 --- /dev/null +++ b/lib/services/screen_recording/backends/native_channel_screen_recorder.dart @@ -0,0 +1,53 @@ +import 'package:bike_control/services/screen_recording/screen_recording_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:screen_recorder/screen_recorder.dart'; + +/// Backend that delegates to the in-repo `screen_recorder` plugin +/// (iOS broadcast bridge / macOS ScreenCaptureKit / Windows WGC). +class NativeChannelScreenRecorder implements ScreenRecorderBackend { + NativeChannelScreenRecorder([ScreenRecorderChannel? channel]) + : _channel = channel ?? ScreenRecorderChannel(); + + final ScreenRecorderChannel _channel; + + @override + Future isAvailable() async { + try { + return await _channel.isSupported(); + } catch (e, s) { + debugPrintStack(label: 'screen_recorder isSupported: $e', stackTrace: s); + return false; + } + } + + @override + Future ensurePermission() async { + try { + if (await _channel.hasPermission()) return true; + return await _channel.requestPermission(); + } catch (e, s) { + debugPrintStack(label: 'screen_recorder permission: $e', stackTrace: s); + return false; + } + } + + @override + Future start() async { + try { + return await _channel.start(); + } catch (e, s) { + debugPrintStack(label: 'screen_recorder start: $e', stackTrace: s); + return false; + } + } + + @override + Future stop() async { + try { + return await _channel.stop(); + } catch (e, s) { + debugPrintStack(label: 'screen_recorder stop: $e', stackTrace: s); + return null; + } + } +} diff --git a/lib/services/screen_recording/backends/unsupported_screen_recorder.dart b/lib/services/screen_recording/backends/unsupported_screen_recorder.dart new file mode 100644 index 00000000..45eb5835 --- /dev/null +++ b/lib/services/screen_recording/backends/unsupported_screen_recorder.dart @@ -0,0 +1,13 @@ +import 'package:bike_control/services/screen_recording/screen_recording_service.dart'; + +/// Backend for platforms/OS versions that cannot capture (web, Linux, macOS < 12.3, old Windows). +class UnsupportedScreenRecorder implements ScreenRecorderBackend { + @override + Future isAvailable() async => false; + @override + Future ensurePermission() async => false; + @override + Future start() async => false; + @override + Future stop() async => null; +} diff --git a/lib/services/screen_recording/screen_recording_service.dart b/lib/services/screen_recording/screen_recording_service.dart new file mode 100644 index 00000000..d5006380 --- /dev/null +++ b/lib/services/screen_recording/screen_recording_service.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bike_control/main.dart'; +import 'package:bike_control/services/screen_recording/backends/android_screen_recorder.dart'; +import 'package:bike_control/services/screen_recording/backends/native_channel_screen_recorder.dart'; +import 'package:bike_control/services/screen_recording/backends/unsupported_screen_recorder.dart'; +import 'package:flutter/foundation.dart'; + +enum ScreenRecordingState { idle, starting, recording, stopping, unsupported, error } + +/// Outcome of a [ScreenRecordingService.toggle]. +class RecordingResult { + final bool ok; + + /// True if this toggle *started* recording; false if it *stopped* (or failed). + final bool startedRecording; + + /// Saved file path when a stop succeeded (desktop surfaces this); null otherwise. + final String? savedPath; + final String? errorMessage; + + const RecordingResult({ + required this.ok, + required this.startedRecording, + this.savedPath, + this.errorMessage, + }); +} + +/// Platform capture backend. Implementations: Android (package), native channel +/// (iOS/macOS/Windows), and unsupported (web/Linux/too-old OS). +abstract class ScreenRecorderBackend { + /// Whether this OS build can capture at all. + Future isAvailable(); + + /// Ensure capture permission, prompting if needed. Returns true if granted. + Future ensurePermission(); + + /// Begin capture. Returns true if recording started. + Future start(); + + /// Stop capture and persist. Returns the saved path (may be null on mobile gallery saves). + Future stop(); +} + +/// Owns recording lifecycle/state. All logic lives here so it is unit-testable +/// with a fake backend; the per-platform native work lives in the backends. +class ScreenRecordingService { + ScreenRecordingService({required ScreenRecorderBackend backend}) : _backend = backend; + + final ScreenRecorderBackend _backend; + final ValueNotifier _state = ValueNotifier(ScreenRecordingState.idle); + + ValueListenable get state => _state; + bool get isRecording => _state.value == ScreenRecordingState.recording; + Future get isAvailable => _backend.isAvailable(); + + /// Toggle recording. Never throws — failures map to `ok: false`. + Future toggle() async { + final state = _state.value; + if (state == ScreenRecordingState.starting || state == ScreenRecordingState.stopping) { + // A start/stop is already in flight (e.g. awaiting the permission sheet); + // ignore re-entrant presses so we don't double-start or stop-during-start. + return const RecordingResult(ok: false, startedRecording: false); + } + if (isRecording) { + return _stop(); + } + return _start(); + } + + Future _start() async { + try { + if (!await _backend.isAvailable()) { + _state.value = ScreenRecordingState.unsupported; + return const RecordingResult(ok: false, startedRecording: false); + } + _state.value = ScreenRecordingState.starting; + if (!await _backend.ensurePermission()) { + _state.value = ScreenRecordingState.idle; + return const RecordingResult(ok: false, startedRecording: false, errorMessage: 'permission denied'); + } + final started = await _backend.start(); + _state.value = started ? ScreenRecordingState.recording : ScreenRecordingState.idle; + return RecordingResult(ok: started, startedRecording: true); + } catch (e, s) { + _state.value = ScreenRecordingState.error; + unawaited(recordError(e, s, context: 'screen recording')); + return RecordingResult(ok: false, startedRecording: false, errorMessage: e.toString()); + } + } + + Future _stop() async { + try { + _state.value = ScreenRecordingState.stopping; + final path = await _backend.stop(); + _state.value = ScreenRecordingState.idle; + return RecordingResult(ok: true, startedRecording: false, savedPath: path); + } catch (e, s) { + _state.value = ScreenRecordingState.error; + unawaited(recordError(e, s, context: 'screen recording')); + return RecordingResult(ok: false, startedRecording: false, errorMessage: e.toString()); + } + } +} + +/// Selects the screen-recording backend for the running platform: Android +/// (package-based), iOS/macOS/Windows (native channel — currently unsupported +/// until native code lands), web/Linux unsupported. +ScreenRecorderBackend createScreenRecorderBackend() { + if (kIsWeb) return UnsupportedScreenRecorder(); + if (Platform.isIOS || Platform.isMacOS || Platform.isWindows) { + return NativeChannelScreenRecorder(); + } + if (Platform.isAndroid) return AndroidScreenRecorder(); + return UnsupportedScreenRecorder(); +} diff --git a/lib/utils/actions/base_actions.dart b/lib/utils/actions/base_actions.dart index e3f15de6..656a3239 100644 --- a/lib/utils/actions/base_actions.dart +++ b/lib/utils/actions/base_actions.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:math'; @@ -5,6 +6,7 @@ import 'package:accessibility/accessibility.dart'; import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart'; import 'package:bike_control/bluetooth/messages/notification.dart'; import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; import 'package:bike_control/services/workout/workout_recorder.dart'; import 'package:bike_control/utils/actions/android.dart'; import 'package:bike_control/utils/actions/desktop.dart'; @@ -29,7 +31,10 @@ sealed class ActionResult { } class Success extends ActionResult { - const Success(super.message, {required super.button}); + /// Saved file path for results that produced a file (e.g. a screen + /// recording). Lets the UI offer an "open folder" action. + final String? filePath; + const Success(super.message, {required super.button, this.filePath}); } class NotHandled extends ActionResult { @@ -121,6 +126,76 @@ abstract class BaseActions { return Offset.zero; } + /// True when the active trainer (the connected proxy device) has the + /// both-shifters front-shift combo enabled in its [ShiftingConfig]. + bool get frontShiftComboEnabled { + final proxy = core.connection.proxyDevices.where((d) => d.isConnected).firstOrNull; + if (proxy == null) return false; + return core.shiftingConfigs.activeFor(proxy.trainerKey).frontShiftEnabled; + } + + // --- Both-shifters combo (coincidence-window detector) --------------------- + // Two-device controllers (e.g. Zwift Play) deliver their two rear shifts as + // separate performAction calls; pressing both shifters together is the SRAM + // gesture for a front (chainring) shift. We detect the opposite shift landing + // within a short window and additively emit a frontShift alongside the + // (mutually cancelling) rear shifts. + + @visibleForTesting + DateTime Function() nowFn = DateTime.now; + static const Duration _frontShiftWindow = Duration(milliseconds: 120); + DateTime? _lastShiftUpAt; + DateTime? _lastShiftDownAt; + + /// Record a rear shift; return true if the OPPOSITE shift occurred within the + /// front-shift window (→ treat as a both-shifters combo). Resets on a hit. + @visibleForTesting + bool noteShiftAndCheckCoincidence(InGameAction action) { + final now = nowFn(); + if (action == InGameAction.shiftUp) { + _lastShiftUpAt = now; + final down = _lastShiftDownAt; + if (down != null && now.difference(down) <= _frontShiftWindow) { + _lastShiftUpAt = null; + _lastShiftDownAt = null; + return true; + } + } else if (action == InGameAction.shiftDown) { + _lastShiftDownAt = now; + final up = _lastShiftUpAt; + if (up != null && now.difference(up) <= _frontShiftWindow) { + _lastShiftUpAt = null; + _lastShiftDownAt = null; + return true; + } + } + return false; + } + + /// Dispatch [action] as if a mapped button fired it, with no physical button. + /// Used by the both-shifters combo (this file's coincidence detector and the + /// same-frame detector in base_device.dart) to emit a frontShift. + Future performInGameAction(InGameAction action) async { + final synthButton = ControllerButton('frontShiftCombo', action: action); + final keyPair = KeyPair( + buttons: [synthButton], + physicalKey: null, + logicalKey: null, + inGameAction: action, + ); + // Direct path: a connected proxy trainer handles it (frontShift toggle). + if (trainerActions.contains(action)) { + final proxy = core.connection.proxyDevices.where((d) => d.isConnected).firstOrNull; + if (proxy != null) { + await IAPManager.instance.incrementCommandCount(); + final result = proxy.handleTrainerAction(synthButton, action); + if (result is Ignored || result is Success) return result; + } + } + // Otherwise forward to the connected app (e.g. Zwift native SRAM combo). + return _handleDirectConnect(keyPair, synthButton, isKeyDown: true, isKeyUp: true); + } + Future performAction( ControllerButton button, { required bool isKeyDown, @@ -144,6 +219,26 @@ abstract class BaseActions { ); } + // Both-shifters combo (coincidence window): two-device controllers (Play) + // deliver each rear shift as its own performAction call. When the opposite + // shift lands within the window, additively fire a frontShift — this does + // NOT suppress the normal shift; the two opposite rear shifts cancel while + // the front toggles. The same-frame case (Ride/Click) is handled at the + // device layer (Task 8), so those never reach this detector. + if (frontShiftComboEnabled && + isKeyDown && + (keyPair.inGameAction == InGameAction.shiftUp || keyPair.inGameAction == InGameAction.shiftDown)) { + if (noteShiftAndCheckCoincidence(keyPair.inGameAction!)) { + unawaited(() async { + try { + await performInGameAction(InGameAction.frontShift); + } catch (e, s) { + recordError(e, s, context: 'frontShiftCombo'); + } + }()); + } + } + final guard = proGuard(button: button, trigger: trigger, keyPair: keyPair); if (guard is! NotHandled) { return guard; @@ -200,6 +295,35 @@ abstract class BaseActions { ); } + // Handle screen recording — device-level toggle, works with no trainer. + if (keyPair.inGameAction == InGameAction.screenRecording) { + if (!isKeyDown) { + return Ignored('', button: keyPair.buttons.firstOrNull ?? button); + } + final svc = core.screenRecording; + if (!await svc.isAvailable) { + return Ignored( + AppLocalizations.current.screenRecordingNotSupported, + button: keyPair.buttons.firstOrNull ?? button, + ); + } + final result = await svc.toggle(); + if (result.ok) { + await IAPManager.instance.incrementCommandCount(); + final stopped = !result.startedRecording; + return Success( + stopped ? AppLocalizations.current.screenRecordingStopped : AppLocalizations.current.screenRecordingStarted, + button: keyPair.buttons.firstOrNull ?? button, + // Carries the saved path so the activity log can offer "open folder". + filePath: stopped ? result.savedPath : null, + ); + } + return Error( + AppLocalizations.current.screenRecordingFailed, + button: keyPair.buttons.firstOrNull ?? button, + ); + } + // Handle trainer-control actions if (trainerActions.contains(keyPair.inGameAction)) { final proxy = core.connection.proxyDevices.where((d) => d.isConnected).firstOrNull; diff --git a/lib/utils/core.dart b/lib/utils/core.dart index c1288ce2..98457f0b 100644 --- a/lib/utils/core.dart +++ b/lib/utils/core.dart @@ -13,6 +13,7 @@ import 'package:bike_control/bluetooth/remote_keyboard_pairing.dart'; import 'package:bike_control/bluetooth/remote_pairing.dart'; import 'package:bike_control/main.dart'; import 'package:bike_control/services/review_prompt_service.dart'; +import 'package:bike_control/services/screen_recording/screen_recording_service.dart'; import 'package:bike_control/services/shifting_configs_controller.dart'; import 'package:bike_control/services/workout/workout_recorder.dart'; import 'package:bike_control/services/workout/workout_repository.dart'; @@ -53,6 +54,7 @@ class Core { late final shiftingConfigs = ShiftingConfigsController(settings.prefs); final connection = Connection(); late final workoutRecorder = WorkoutRecorder(); + ScreenRecordingService screenRecording = ScreenRecordingService(backend: createScreenRecorderBackend()); late final workoutRepository = WorkoutRepository(); late final supabase = Supabase.instance.client; diff --git a/lib/utils/gear_readout.dart b/lib/utils/gear_readout.dart new file mode 100644 index 00000000..c9eed5ed --- /dev/null +++ b/lib/utils/gear_readout.dart @@ -0,0 +1,16 @@ +/// Formats the compact gear readout shown in the proxy card, desktop overlay, +/// PiP, and live-gear surfaces. +/// +/// Without front shift it's the familiar rear `gear/total` (e.g. `14/25`). +/// With the virtual front derailleur on, it switches to head-unit-style +/// position notation `front×rear` — small ring is position 1, large ring is +/// position 2 (e.g. `2×14`), matching how Garmin/Wahoo show Di2/AXS gearing. +String formatGearReadout({ + required int currentGear, + required int maxGear, + required bool frontShiftEnabled, + required bool largeRing, +}) { + if (!frontShiftEnabled) return '$currentGear/$maxGear'; + return '${largeRing ? 2 : 1}×$currentGear'; +} diff --git a/lib/utils/help_article.dart b/lib/utils/help_article.dart new file mode 100644 index 00000000..a60e0b00 --- /dev/null +++ b/lib/utils/help_article.dart @@ -0,0 +1,64 @@ +import 'package:bike_control/bluetooth/devices/base_device.dart'; +import 'package:bike_control/bluetooth/devices/cycplus/cycplus_bc2.dart'; +import 'package:bike_control/bluetooth/devices/elite/elite_square.dart'; +import 'package:bike_control/bluetooth/devices/elite/elite_sterzo.dart'; +import 'package:bike_control/bluetooth/devices/gamepad/gamepad_device.dart'; +import 'package:bike_control/bluetooth/devices/hid/hid_device.dart'; +import 'package:bike_control/bluetooth/devices/shimano/shimano_di2.dart'; +import 'package:bike_control/bluetooth/devices/sram/sram_axs.dart'; +import 'package:bike_control/bluetooth/devices/thinkrider/thinkrider_vs200.dart'; +import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_click.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_play.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:flutter/widgets.dart'; + +/// A link to a bikecontrol.app how-to article for a controller + trainer app. +class HelpArticle { + const HelpArticle({required this.url, required this.label}); + final String url; + final String label; +} + +/// The how-to-connect article for [controller] + the active trainer [app], or +/// null when [controller] is null or has no dedicated page (so callers can hide +/// the entry). The trainer app falls back to the generic "other-training-app" +/// page when [app] is null or has no page of its own. +HelpArticle? helpArticleFor( + BuildContext context, { + required BaseDevice? controller, + required SupportedApp? app, +}) { + if (controller == null) return null; + final ctrl = _controllerArticle(controller); + if (ctrl == null) return null; + final appSlug = app?.helpSlug ?? 'other-training-app'; + final appName = app?.name ?? 'your trainer app'; + return HelpArticle( + url: 'https://bikecontrol.app/use-${ctrl.slug}-with-$appSlug/', + label: AppLocalizations.of(context).useControllerWithApp(ctrl.name, appName), + ); +} + +/// Maps a controller device to its bikecontrol.app slug + display name, or null +/// for device types without a dedicated page (e.g. gyroscope steering, a smart +/// trainer proxy). `ZwiftClickV2 extends ZwiftRide` — keep the V2 check first. +({String slug, String name})? _controllerArticle(BaseDevice device) { + if (device is ZwiftClickV2) return (slug: 'zwift-click-v2', name: 'Zwift Click V2'); + if (device is ZwiftClick) return (slug: 'zwift-click', name: 'Zwift Click'); + if (device is ZwiftPlay) return (slug: 'zwift-play', name: 'Zwift Play'); + if (device is ZwiftRide) return (slug: 'zwift-ride', name: 'Zwift Ride'); + if (device is ShimanoDi2) return (slug: 'shimano-di2', name: 'Shimano Di2'); + if (device is SramAxs) return (slug: 'sram-axs-etap', name: 'SRAM AXS'); + if (device is WahooKickrBikeShift) return (slug: 'wahoo-kickr-bike-shift', name: 'Wahoo KICKR Bike Shift'); + if (device is CycplusBc2) return (slug: 'cycplus-bc2-virtual-shifter', name: 'CYCPLUS BC2'); + if (device is EliteSquare) return (slug: 'elite-square-smart-frame', name: 'Elite Square'); + if (device is EliteSterzo) return (slug: 'elite-sterzo-smart', name: 'Elite Sterzo'); + if (device is ThinkRiderVs200) return (slug: 'thinkrider-vs200', name: 'ThinkRider VS200'); + if (device is GamepadDevice) return (slug: 'gamepads', name: 'Gamepad'); + if (device is HidDevice) return (slug: 'keyboard-input', name: 'Keyboard'); + return null; +} diff --git a/lib/utils/iap/iap_manager.dart b/lib/utils/iap/iap_manager.dart index d0ea08e9..985b8a48 100644 --- a/lib/utils/iap/iap_manager.dart +++ b/lib/utils/iap/iap_manager.dart @@ -74,6 +74,14 @@ class IAPManager { return isProEnabledForCurrentDevice || hasPurchasedBefore50RVC; } + /// Test-only: force the Pro entitlement state so Pro-gated actions/UI can be + /// exercised without a live subscription or device registration. + @visibleForTesting + void setProForTesting({required bool enabled}) { + _isInitialized = true; + isLocalPro.value = enabled; + } + bool get hasPurchasedBefore50RVC => isPurchased.value && ((_revenueCatService?.hasPurchasedBefore50 ?? false) || (_windowsIapService?.hasPurchasedBefore50 ?? false)); diff --git a/lib/utils/keymap/apps/biketerra.dart b/lib/utils/keymap/apps/biketerra.dart index d2af2933..892739d1 100644 --- a/lib/utils/keymap/apps/biketerra.dart +++ b/lib/utils/keymap/apps/biketerra.dart @@ -6,6 +6,9 @@ import '../buttons.dart'; import '../keymap.dart'; class Biketerra extends SupportedApp { + @override + String get helpSlug => 'biketerra'; + @override List<(AppConnectionMethod, ConnectionSupport)> get connections => [ (AppConnectionMethod.zwiftMdns, ConnectionSupport.supported), diff --git a/lib/utils/keymap/apps/my_whoosh.dart b/lib/utils/keymap/apps/my_whoosh.dart index 5ea1fadf..d4f3f63e 100644 --- a/lib/utils/keymap/apps/my_whoosh.dart +++ b/lib/utils/keymap/apps/my_whoosh.dart @@ -16,6 +16,9 @@ class MyWhoosh extends SupportedApp { @override String? get logoAsset => 'assets/mywhoosh.png'; + @override + String get helpSlug => 'mywhoosh'; + @override int get virtualGearAmount => 30; diff --git a/lib/utils/keymap/apps/rouvy.dart b/lib/utils/keymap/apps/rouvy.dart index 1288ba15..2403ab70 100644 --- a/lib/utils/keymap/apps/rouvy.dart +++ b/lib/utils/keymap/apps/rouvy.dart @@ -16,6 +16,9 @@ class Rouvy extends SupportedApp { @override String? get logoAsset => 'assets/rouvy.png'; + @override + String get helpSlug => 'rouvy'; + /// Maps Zwift Click V2 button actions to Rouvy-specific actions. /// See: https://support.rouvy.com/hc/de/articles/32452137189393-Virtuelles-Schalten @override diff --git a/lib/utils/keymap/apps/supported_app.dart b/lib/utils/keymap/apps/supported_app.dart index 47b48925..14d4dbb0 100644 --- a/lib/utils/keymap/apps/supported_app.dart +++ b/lib/utils/keymap/apps/supported_app.dart @@ -60,6 +60,10 @@ abstract class SupportedApp { /// Optional asset path for the trainer app logo (only for officially supported apps). String? get logoAsset => null; + /// Slug for the bikecontrol.app `use--with-` how-to article. + /// Apps without a dedicated page fall back to the generic guide. + String get helpSlug => 'other-training-app'; + /// Maps Zwift Click V2 actions to this app's corresponding actions. /// E.g. for Rouvy: {InGameAction.usePowerUp: InGameAction.pause, InGameAction.select: InGameAction.kudos} Map get inGameActionsMapping => const {}; diff --git a/lib/utils/keymap/apps/training_peaks.dart b/lib/utils/keymap/apps/training_peaks.dart index 51d3f016..474435d6 100644 --- a/lib/utils/keymap/apps/training_peaks.dart +++ b/lib/utils/keymap/apps/training_peaks.dart @@ -18,6 +18,9 @@ class TrainingPeaks extends SupportedApp { @override String? get logoAsset => 'assets/trainingpeaks.png'; + @override + String get helpSlug => 'trainingpeaks'; + @override List get defaultObpSupportedButtons => const [ 0x01, // Shift Up diff --git a/lib/utils/keymap/apps/zwift.dart b/lib/utils/keymap/apps/zwift.dart index 9bd79b9d..c5247772 100644 --- a/lib/utils/keymap/apps/zwift.dart +++ b/lib/utils/keymap/apps/zwift.dart @@ -6,6 +6,9 @@ import 'package:flutter/services.dart'; import '../keymap.dart'; class Zwift extends SupportedApp { + @override + String get helpSlug => 'zwift'; + @override List<(AppConnectionMethod, ConnectionSupport)> get connections => [ (AppConnectionMethod.zwiftMdns, ConnectionSupport.supported), diff --git a/lib/utils/keymap/buttons.dart b/lib/utils/keymap/buttons.dart index 2fd5cb7c..4ac6036b 100644 --- a/lib/utils/keymap/buttons.dart +++ b/lib/utils/keymap/buttons.dart @@ -69,12 +69,16 @@ enum InGameAction { trainerIntensityUp('Trainer: Intensity Up', icon: LucideIcons.trendingUp, isOutsideTrainerApp: true), trainerIntensityDown('Trainer: Intensity Down', icon: LucideIcons.trendingDown, isOutsideTrainerApp: true), workoutPauseResume('Workout: Pause/Resume', icon: LucideIcons.pause, isOutsideTrainerApp: true), + frontShift('Front Shift (Chainring)', icon: LucideIcons.arrowLeftRight, isOutsideTrainerApp: true), // Wahoo ELEMNT — D-Fly channel buttons emitted via the Di2Definition. dFlyChannel1('D-Fly Channel 1', icon: LucideIcons.circleDot), dFlyChannel2('D-Fly Channel 2', icon: LucideIcons.circleDot), dFlyChannel3('D-Fly Channel 3', icon: LucideIcons.circleDot), - dFlyChannel4('D-Fly Channel 4', icon: LucideIcons.circleDot); + dFlyChannel4('D-Fly Channel 4', icon: LucideIcons.circleDot), + + // device / system + screenRecording('Record Screen', icon: LucideIcons.video, isOutsideTrainerApp: true); final String englishTitle; final bool isLongPress; @@ -142,10 +146,12 @@ enum InGameAction { InGameAction.trainerIntensityUp => l.actionTrainerIntensityUp, InGameAction.trainerIntensityDown => l.actionTrainerIntensityDown, InGameAction.workoutPauseResume => l.actionWorkoutPauseResume, + InGameAction.frontShift => l.actionFrontShift, InGameAction.dFlyChannel1 => l.actionDFlyChannel1, InGameAction.dFlyChannel2 => l.actionDFlyChannel2, InGameAction.dFlyChannel3 => l.actionDFlyChannel3, InGameAction.dFlyChannel4 => l.actionDFlyChannel4, + InGameAction.screenRecording => l.actionScreenRecording, }; } @@ -160,6 +166,7 @@ const trainerActions = [ InGameAction.trainerIntensityUp, InGameAction.trainerIntensityDown, InGameAction.workoutPauseResume, + InGameAction.frontShift, ]; const trainerOnlyActions = [ diff --git a/lib/utils/keymap/keymap.dart b/lib/utils/keymap/keymap.dart index 649d28a0..7312dcb2 100644 --- a/lib/utils/keymap/keymap.dart +++ b/lib/utils/keymap/keymap.dart @@ -636,6 +636,7 @@ class KeyPair { command != null && command!.trim().isNotEmpty || screenshotPath != null && screenshotPath!.trim().isNotEmpty || isSpecialKey || + inGameAction == InGameAction.screenRecording || (androidAction != null && core.logic.showLocalControl && core.actionHandler is AndroidActions) || (androidIntentAction != null && androidIntentAction!.trim().isNotEmpty && diff --git a/lib/utils/requirements/multi.dart b/lib/utils/requirements/multi.dart index 482ac8af..c9ae0fda 100644 --- a/lib/utils/requirements/multi.dart +++ b/lib/utils/requirements/multi.dart @@ -57,7 +57,7 @@ class BluetoothTurnedOn extends PlatformRequirement { final currentState = await UniversalBle.getBluetoothAvailabilityState(); if (!kIsWeb && Platform.isIOS) { // on iOS we cannot programmatically enable Bluetooth, just open settings - await openAppSettings(); + await UniversalBle.requestPermissions(); } else if (currentState == AvailabilityState.poweredOff) { if (Platform.isMacOS) { buildToast(title: name); diff --git a/lib/utils/settings/settings.dart b/lib/utils/settings/settings.dart index 39d27db5..9d93afce 100644 --- a/lib/utils/settings/settings.dart +++ b/lib/utils/settings/settings.dart @@ -651,6 +651,20 @@ class Settings { await prefs.setBool('overlay_enabled', enabled); } + /// iOS only: whether to use the floating Picture-in-Picture overlay. + /// `null` = automatic (device default — on for iPad and non-Dynamic-Island + /// iPhones, off for Dynamic-Island iPhones, which use the Live Activity). + /// `true`/`false` = explicit user override. + bool? getOverlayUsePip() => prefs.getBool('overlay_use_pip'); + + Future setOverlayUsePip(bool? value) async { + if (value == null) { + await prefs.remove('overlay_use_pip'); + } else { + await prefs.setBool('overlay_use_pip', value); + } + } + /// Get overlay display fields (set of OverlayField enum values). /// Defaults to {power, cadence}. Set getOverlayFields() { diff --git a/lib/widgets/apps/zwift_tile.dart b/lib/widgets/apps/zwift_tile.dart index 65491863..527a4577 100644 --- a/lib/widgets/apps/zwift_tile.dart +++ b/lib/widgets/apps/zwift_tile.dart @@ -53,7 +53,11 @@ class _ZwiftTileState extends State { core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, e.toString())); }); } - setState(() {}); + // onChange awaits stopAllBleConnections above; the tile can + // be disposed mid-await (rapid toggling while advertising + // keeps failing), so guard setState — calling it unmounted + // throws "Null check operator used on a null value". + if (mounted) setState(() {}); }, title: context.i18n.connectUsingBluetooth, description: !isStarted diff --git a/lib/widgets/diagnostics_section.dart b/lib/widgets/diagnostics_section.dart new file mode 100644 index 00000000..e8d393c2 --- /dev/null +++ b/lib/widgets/diagnostics_section.dart @@ -0,0 +1,70 @@ +import 'package:bike_control/services/debug_diagnostics.dart'; +import 'package:flutter/material.dart' show SelectionArea; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Renders a [DebugDiagnostics] snapshot (advertised + discovered mDNS, +/// interfaces, servers, permissions) above the log list. Pure: it takes the +/// snapshot and a refresh callback, so it is testable without app globals. +class DiagnosticsSection extends StatelessWidget { + final DebugDiagnostics? diagnostics; + final bool scanning; + final VoidCallback onRefresh; + + const DiagnosticsSection({ + super.key, + required this.diagnostics, + required this.scanning, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + final diag = diagnostics; + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Diagnostics').bold, + Row( + children: [ + if (scanning) Text('scanning…').muted, + IconButton.ghost( + key: const ValueKey('diagnostics-refresh'), + icon: Icon(LucideIcons.refreshCw, size: 18), + onPressed: scanning ? null : onRefresh, + ), + ], + ), + ], + ), + if (diag != null) + // Bounded + scrollable: a long block must not overflow the Logs + // Column (which has an Expanded log list below it). + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: SingleChildScrollView( + child: SelectionArea( + child: SizedBox( + width: double.infinity, + child: Text( + diag.toText(), + style: const TextStyle( + fontFamily: 'monospace', + fontFamilyFallback: ['Courier'], + fontSize: 11, + ), + ), + ), + ), + ), + ) + else if (!scanning) + Text('No diagnostics yet').muted, + ], + ), + ); + } +} diff --git a/lib/widgets/logviewer.dart b/lib/widgets/logviewer.dart index 1d91e220..7bcaa492 100644 --- a/lib/widgets/logviewer.dart +++ b/lib/widgets/logviewer.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'dart:io'; +import 'package:bike_control/services/debug_diagnostics.dart'; import 'package:bike_control/utils/core.dart'; import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/diagnostics_section.dart'; import 'package:bike_control/widgets/ui/toast.dart'; import 'package:dartx/dartx.dart'; import 'package:flutter/foundation.dart'; @@ -22,6 +24,18 @@ class LogViewer extends StatefulWidget { class _LogviewerState extends State { late StreamSubscription _actionSubscription; final ScrollController _scrollController = ScrollController(); + DebugDiagnostics? _diagnostics; + bool _scanning = false; + + Future _loadDiagnostics() async { + setState(() => _scanning = true); + final diag = await DebugDiagnostics.gather(includeDiscovery: true); + if (!mounted) return; + setState(() { + _diagnostics = diag; + _scanning = false; + }); + } @override void initState() { @@ -40,6 +54,7 @@ class _LogviewerState extends State { } } }); + _loadDiagnostics(); } @override @@ -76,6 +91,11 @@ class _LogviewerState extends State { crossAxisAlignment: CrossAxisAlignment.start, spacing: 12, children: [ + DiagnosticsSection( + diagnostics: _diagnostics, + scanning: _scanning, + onRefresh: _loadDiagnostics, + ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -86,7 +106,10 @@ class _LogviewerState extends State { final logText = core.connection.lastLogEntries .map((entry) => '${entry.date.toString().split(" ").last} ${entry.entry}') .join('\n'); - Clipboard.setData(ClipboardData(text: logText)); + final diagnosticsText = _diagnostics?.toText(); + final shareText = + diagnosticsText == null ? logText : '$diagnosticsText\n\nLogs:\n$logText'; + Clipboard.setData(ClipboardData(text: shareText)); buildToast(title: context.i18n.logsHaveBeenCopiedToClipboard); }, diff --git a/lib/widgets/menu.dart b/lib/widgets/menu.dart index e18a4326..bd20cbff 100644 --- a/lib/widgets/menu.dart +++ b/lib/widgets/menu.dart @@ -6,6 +6,7 @@ import 'package:bike_control/pages/paywall.dart'; import 'package:bike_control/pages/subscription.dart'; import 'package:bike_control/services/telemetry_snapshot.dart'; import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/gear_readout.dart'; import 'package:bike_control/utils/i18n_extension.dart'; import 'package:bike_control/widgets/logviewer.dart'; import 'package:bike_control/widgets/title.dart'; @@ -23,6 +24,8 @@ import 'package:universal_ble/universal_ble.dart'; import '../bluetooth/devices/zwift/zwift_clickv2.dart'; import '../utils/iap/iap_manager.dart'; +import 'package:bike_control/services/debug_diagnostics.dart'; +import 'package:bike_control/main.dart' show recordError; List buildMenuButtons(BuildContext context) { final iap = IAPManager.instance; @@ -95,10 +98,18 @@ List buildMenuButtons(BuildContext context) { ]; } -Future debugText() async { +Future debugText({bool includeDiscovery = true}) async { final userId = IAPManager.instance.isUsingRevenueCat ? (await Purchases.appUserID) : null; final proxies = core.connection.proxyDevices; final proxyBlock = proxies.isEmpty ? '-' : proxies.map(_describeProxyDevice).join('\n '); + String diagnostics; + try { + final diag = await DebugDiagnostics.gather(includeDiscovery: includeDiscovery); + diagnostics = diag.toText(); + } catch (e, s) { + recordError(e, s, context: 'debugText.diagnostics'); + diagnostics = 'Diagnostics: (unavailable)'; + } return ''' --- @@ -111,6 +122,7 @@ Connected Trainers: ${core.logic.connectedTrainerConnections.map((e) => e.title) Smart Trainers: $proxyBlock Status: ${IAPManager.instance.getStatusMessage()}${userId != null ? ' (User ID: $userId)' : ''} +$diagnostics Logs: ${core.connection.lastLogEntries.reversed.joinToString(separator: '\n', transform: (e) => '${e.date.toString().split('.').first} - ${e.entry}')} '''; @@ -144,7 +156,9 @@ String _describeProxyDevice(ProxyDevice device) { if (device.firmwareVersion != null) parts.add('fw=${device.firmwareVersion}'); if (device.manufacturerName != null) parts.add('mfg=${device.manufacturerName}'); if (def != null) { - parts.add('gear=${def.currentGear.value}/${def.maxGear}'); + parts.add( + 'gear=${formatGearReadout(currentGear: def.currentGear.value, maxGear: def.maxGear, frontShiftEnabled: def.frontShiftEnabled, largeRing: def.frontRing.value == FrontRing.large)}', + ); parts.add('trainerMode=${def.trainerMode.value.name}'); } diff --git a/lib/widgets/overlay/trainer_overlay_view.dart b/lib/widgets/overlay/trainer_overlay_view.dart index 5079e20d..c5f12f21 100644 --- a/lib/widgets/overlay/trainer_overlay_view.dart +++ b/lib/widgets/overlay/trainer_overlay_view.dart @@ -1,4 +1,5 @@ import 'package:bike_control/services/overlay/overlay_state.dart'; +import 'package:bike_control/utils/gear_readout.dart'; import 'package:flutter/foundation.dart'; import 'package:prop/emulators/definitions/fitness_bike_definition.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -51,7 +52,7 @@ class TrainerOverlayView extends StatelessWidget { border: Border.all(color: cs.border), ) : null, - padding: const EdgeInsets.fromLTRB(10, 6, 6, 8), + padding: const EdgeInsets.fromLTRB(8, 6, 8, 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -72,7 +73,14 @@ class TrainerOverlayView extends StatelessWidget { /// edge. Drag handle is always trailing. Widget _primaryRow(BuildContext context, ColorScheme cs, TrainerOverlayState s) { final isErg = s.mode == TrainerMode.ergMode; - final primary = isErg ? '${s.ergTargetW ?? '--'} W' : '${s.gear} / ${s.maxGear}'; + final primary = isErg + ? '${s.ergTargetW ?? '--'} W' + : formatGearReadout( + currentGear: s.gear, + maxGear: s.maxGear, + frontShiftEnabled: s.frontShiftEnabled, + largeRing: s.frontRingLarge, + ); final showControls = s.fields.contains(OverlayField.controls); final primaryText = FittedBox( @@ -107,25 +115,37 @@ class TrainerOverlayView extends StatelessWidget { height: showControls ? 48 : 36, child: Row( children: [ - if (!showControls) - const Padding( - padding: EdgeInsets.only(right: 6), - child: Image( - image: AssetImage('icon.png'), - width: 18, - height: 18, - ), - ), + // Equal-width leading/trailing slots keep the primary value centred + // whether or not the app icon / drag handle is present. + SizedBox( + width: 24, + child: showControls + ? null + : const Align( + alignment: Alignment.centerLeft, + child: Image( + image: AssetImage('icon.png'), + width: 18, + height: 18, + ), + ), + ), Expanded(child: Center(child: primaryBlock)), - if (onDragStart != null) - GestureDetector( - behavior: HitTestBehavior.opaque, - onPanStart: (_) => onDragStart!(), - child: Padding( - padding: const EdgeInsets.all(2), - child: Icon(Icons.drag_indicator, size: 14, color: cs.mutedForeground), - ), - ), + SizedBox( + width: 24, + child: onDragStart != null + // GestureDetector fills the whole slot (opaque) so the entire + // 24px trailing area is draggable, not just the 14px icon. + ? GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (_) => onDragStart!(), + child: Align( + alignment: Alignment.centerRight, + child: Icon(Icons.drag_indicator, size: 14, color: cs.mutedForeground), + ), + ) + : null, + ), ], ), ); @@ -199,7 +219,7 @@ class TrainerOverlayView extends StatelessWidget { } return Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0), + padding: const EdgeInsets.symmetric(horizontal: 2.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, diff --git a/lib/widgets/ui/connection_method.dart b/lib/widgets/ui/connection_method.dart index c225b836..fe529a2c 100644 --- a/lib/widgets/ui/connection_method.dart +++ b/lib/widgets/ui/connection_method.dart @@ -112,11 +112,16 @@ class _ConnectionMethodState extends State with WidgetsBinding widget.onChange(!widget.isEnabled); } else { Future.wait(widget.requirements.map((e) => e.getStatus())).then((_) async { + // The widget can be disposed across these async gaps; using a defunct + // context (openPermissionSheet) or setState then throws "Null check + // operator used on a null value". + if (!context.mounted) return; final notDone = widget.requirements.filter((e) => !e.status).toList(); if (notDone.isEmpty) { widget.onChange(!widget.isEnabled); } else { await openPermissionSheet(context, notDone); + if (!context.mounted) return; _recheckRequirements(); setState(() {}); } diff --git a/lib/widgets/ui/help_button.dart b/lib/widgets/ui/help_button.dart index af9b0970..5bce4a92 100644 --- a/lib/widgets/ui/help_button.dart +++ b/lib/widgets/ui/help_button.dart @@ -6,6 +6,7 @@ import 'package:bike_control/services/support_chat_models.dart'; import 'package:bike_control/services/support_chat_service.dart'; import 'package:bike_control/services/telemetry_snapshot.dart'; import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/help_article.dart'; import 'package:bike_control/utils/i18n_extension.dart'; import 'package:bike_control/widgets/menu.dart'; import 'package:bike_control/widgets/ui/colored_title.dart'; @@ -69,11 +70,23 @@ class _HelpButtonState extends State { builder: (context) { return Button( onPressed: () { + final controllers = core.connection.controllerDevices; + final article = helpArticleFor( + context, + controller: controllers.isEmpty ? null : controllers.first, + app: core.settings.getTrainerApp(), + ); showDropdown( context: context, builder: (c) => DropdownMenu( children: [ MenuLabel(child: Text(context.i18n.instructions)), + if (article != null) + MenuButton( + leading: Icon(Icons.menu_book_outlined), + child: Text(article.label), + onPressed: (c) => launchUrlString(article.url), + ), MenuButton( leading: Icon(Icons.ondemand_video), child: const Text('Instruction Videos'), @@ -130,25 +143,17 @@ class _HelpButtonState extends State { : Text(context.i18n.chatWithSupport), onPressed: (c) async { final screenshot = await captureOverviewScreenshot(context: context); - final captured = await debugText(); - String? capturedFreetext = captured; + // Gather diagnostics in the background so the chat opens + // immediately; the page awaits this future lazily for the + // preview and at send time (it resolves once and is reused). + final debugFuture = debugText(); await Navigator.of(context).push( MaterialPageRoute( builder: (_) => SupportChatPage( - diagnosticPreview: captured, + diagnosticPreviewFuture: debugFuture, initialAttachment: screenshot, - telemetryBuilder: () async { - if (capturedFreetext != null) { - final snapshot = TelemetrySnapshot.general( - freetext: capturedFreetext, - ); - capturedFreetext = null; - return snapshot; - } - return TelemetrySnapshot.general( - freetext: await debugText(), - ); - }, + telemetryBuilder: () async => + TelemetrySnapshot.general(freetext: await debugFuture), ), ), ); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3b3ce37c..97b17c95 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,6 +15,7 @@ import flutter_local_notifications import flutter_screen_capture import flutter_secure_storage_darwin import flutter_volume_controller +import gal import gamepads_darwin import google_sign_in_ios import in_app_purchase_storekit @@ -27,6 +28,7 @@ import nsd_macos import package_info_plus import path_provider_foundation import purchases_flutter +import screen_recorder import screen_retriever_macos import share_plus import shared_preferences_foundation @@ -47,6 +49,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterScreenCapturePlugin.register(with: registry.registrar(forPlugin: "FlutterScreenCapturePlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterVolumeControllerPlugin.register(with: registry.registrar(forPlugin: "FlutterVolumeControllerPlugin")) + GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) GamepadsDarwinPlugin.register(with: registry.registrar(forPlugin: "GamepadsDarwinPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin")) @@ -59,6 +62,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PurchasesFlutterPlugin.register(with: registry.registrar(forPlugin: "PurchasesFlutterPlugin")) + ScreenRecorderPlugin.register(with: registry.registrar(forPlugin: "ScreenRecorderPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index eb366a26..49f6c7d0 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -14,6 +14,8 @@ PODS: - FlutterMacOS - nsd_macos (0.0.1): - FlutterMacOS + - screen_recorder (0.0.1): + - FlutterMacOS - screen_retriever_macos (0.0.1): - FlutterMacOS - sign_in_with_apple (0.0.1): @@ -28,6 +30,7 @@ DEPENDENCIES: - media_key_detector_macos (from `Flutter/ephemeral/.symlinks/plugins/media_key_detector_macos/macos`) - multi_window_native (from `Flutter/ephemeral/.symlinks/plugins/multi_window_native/macos`) - nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`) + - screen_recorder (from `Flutter/ephemeral/.symlinks/plugins/screen_recorder/macos`) - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) - sign_in_with_apple (from `Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos`) @@ -48,6 +51,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/multi_window_native/macos nsd_macos: :path: Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos + screen_recorder: + :path: Flutter/ephemeral/.symlinks/plugins/screen_recorder/macos screen_retriever_macos: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos sign_in_with_apple: @@ -62,6 +67,7 @@ SPEC CHECKSUMS: media_key_detector_macos: f2fecf51b0cc30d6e2e4605410ae5602ff067ed8 multi_window_native: 090b0376429681c0ea21fee77f0a33c437d36fed nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3 + screen_recorder: b1a6a675694fd381a2aa66c56360a08416d70224 screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f sign_in_with_apple: 6673c03c9e3643f6c8d33601943fbfa9ae99f94e diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 68caaa7c..3afc7c2e 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -18,6 +18,8 @@ com.apple.security.network.server + com.apple.security.assets.movies.read-write + keychain-access-groups $(AppIdentifierPrefix)com.google.GIDSignIn diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index c5ff71ce..991dbbc0 100755 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -18,6 +18,8 @@ com.apple.security.network.server + com.apple.security.assets.movies.read-write + keychain-access-groups $(AppIdentifierPrefix)com.google.GIDSignIn diff --git a/packages/screen_recorder/NATIVE_SETUP.md b/packages/screen_recorder/NATIVE_SETUP.md new file mode 100644 index 00000000..ae0b543b --- /dev/null +++ b/packages/screen_recorder/NATIVE_SETUP.md @@ -0,0 +1,40 @@ +# screen_recorder — native backend setup & status + +Video-only screen recording. Dart spine + Android are done and tested. The three native +backends below were implemented on the `feat/screen-recording` branch; macOS and the iOS +plugin compile here, Windows and the iOS extension target need your machines. + +## Status + +| Platform | Backend | Verified | +|----------|-----------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------| +| Android | `flutter_screen_recording` + `gal` (in `lib/services/.../android_screen_recorder.dart`) | ✅ builds APK, tested | +| macOS | ScreenCaptureKit → AVAssetWriter (`macos/Classes/`) | ✅ `flutter build macos` passes — runtime (TCC + capture) still needs a Mac | +| iOS | ReplayKit broadcast: plugin picker bridge + Broadcast Upload Extension | ⚠️ plugin compiles (`flutter build ios` passes); **extension target needs Xcode wiring** | +| Windows | Windows.Graphics.Capture + Media Foundation (`windows/`) | ❌ **never compiled — build on Windows and iterate** | + +## macOS — remaining +- Runtime test on a Mac: bind a key to "Record Screen", grant the Screen Recording TCC prompt (may need an app relaunch), record, confirm an mp4 in `~/Movies/BikeControl/`. +- If the App Sandbox blocks writing to `~/Movies`, fall back to an `NSOpenPanel`-selected folder or the app container's Movies dir (entitlement `com.apple.security.assets.movies.read-write` is already added). + +## iOS — remaining (manual Xcode + portal, can't be scripted) +Identifiers (derived from the real app bundle id `de.jonasbark.swiftcontrol.darwin`): +- App Group: **reuse the app's existing** `group.de.jonasbark.swiftcontrol.overlay` (already on Runner + TrainerActivityExtension; no new portal group needed) +- Extension bundle id: `de.jonasbark.swiftcontrol.darwin.ScreenRecordBroadcast` + +Steps: +1. **Xcode:** File ▸ New ▸ Target ▸ Broadcast Upload Extension, name `ScreenRecordBroadcast`, bundle id `de.jonasbark.swiftcontrol.darwin.ScreenRecordBroadcast`, **uncheck** "Include UI Extension". Replace the generated `SampleHandler.swift` with the repo's `ios/ScreenRecordBroadcast/SampleHandler.swift` (and use the repo's `Info.plist`). +2. **App Group (the critical step):** in the extension target's **Signing & Capabilities**, add the **App Groups** capability and **check the existing** `group.de.jonasbark.swiftcontrol.overlay`. The Runner target already has it. If this is missing on the extension, `broadcastStarted` can't resolve its container — the recording cannot be stopped from the app or saved. +3. **Signing:** set the team/provisioning on the extension target. +4. Verify the `preferredExtension` string in `packages/screen_recorder/ios/Classes/ScreenRecorderPlugin.swift` matches the extension's bundle id. +5. Device test: bind a key, tap **Start Broadcast** on the system sheet (one unavoidable tap), switch to a game, stop, confirm the broadcast ends and the mp4 lands in Photos. Watch Console.app (filter `SampleHandler`/`ScreenRecorderPlugin`) — you should see `posting stop notification` → `stop notification received -> finishBroadcastWithError` → `broadcastFinished`. +- **Known follow-up (save handoff):** `stop()` reads `lastRecordingPath` from the App Group right after posting the stop notification, but the extension writes it in `broadcastFinished` (async) — so the path can come back `nil` on the first stop even when the recording itself stops fine. Add a short poll (e.g. up to 2 s at 100 ms) for the path before returning. Tackle this after confirming stop works. + +## Windows — remaining (build + iterate, never compiled) +1. `flutter build windows --debug` — expect compile iteration on C++/WinRT headers and `windowsapp.lib`. +2. The `FrameArrived` D3D→Media Foundation path has `// VERIFY on Windows:` markers at the 5 likely trouble points (stride/orientation, even dimensions, thread safety, QPC, BGRA vs ARGB). Record 5 s, confirm a playable mp4; if upside-down, negate `MF_MT_DEFAULT_STRIDE`; if colors swapped, try `MFVideoFormat_ARGB32`. +3. Windows N/KN editions need the Media Feature Pack for the H.264 MFT — surface a message if `MFCreateSinkWriterFromURL` fails. +4. After `flutter pub run msix:create`, confirm `` is in the generated AppxManifest (added via `msix_config.capabilities`). + +## Plugin pubspec note +`pubspec.yaml` now declares ios/macos/windows. If you ever need to temporarily disable a platform whose native code doesn't yet build, remove just that entry (declaring a platform with no/broken native code breaks that platform's plugin registrant — this was the original cross-platform-build trap). diff --git a/packages/screen_recorder/ios/Classes/ScreenRecorderPlugin.swift b/packages/screen_recorder/ios/Classes/ScreenRecorderPlugin.swift new file mode 100644 index 00000000..e7824973 --- /dev/null +++ b/packages/screen_recorder/ios/Classes/ScreenRecorderPlugin.swift @@ -0,0 +1,121 @@ +import Flutter +import UIKit +import ReplayKit + +// IMPORTANT: This reuses the app's EXISTING App Group "group.de.jonasbark.swiftcontrol.overlay" +// (already configured on the Runner target and in the portal). It must ALSO be enabled on the +// ScreenRecordBroadcast extension target (Signing & Capabilities → App Groups → check the +// existing group). Without it the extension can't resolve its shared container, and the +// broadcast can be neither stopped nor saved. +// +// App bundle ID: de.jonasbark.swiftcontrol.darwin +// Shared App Group: group.de.jonasbark.swiftcontrol.overlay +// Extension bundle ID: de.jonasbark.swiftcontrol.darwin.ScreenRecordBroadcast + +public class ScreenRecorderPlugin: NSObject, FlutterPlugin { + // Reuse the app's existing shared App Group (already on the Runner target). + static let appGroup = "group.de.jonasbark.swiftcontrol.overlay" + + // Darwin notification name used to signal the extension to stop. + // Must match the string used in SampleHandler.swift. + static let stopNotificationName = "de.jonasbark.swiftcontrol.darwin.stopBroadcast" + + // The extension's bundle id. Must match the Target's Bundle Identifier set in Xcode. + static let extensionBundleId = "de.jonasbark.swiftcontrol.darwin.ScreenRecordBroadcast" + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "screen_recorder", binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(ScreenRecorderPlugin(), channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "isSupported": + result(true) + case "hasPermission", "requestPermission": + // Broadcast is user-initiated via system sheet; no separate permission required. + result(true) + case "start": + // Clear any stale stop flag so a fresh recording isn't immediately stopped. + clearStopFlag() + sharedDefaults()?.removeObject(forKey: "lastRecordingPath") + presentBroadcastPicker() + sharedDefaults()?.set(true, forKey: "recordingRequested") + result(true) + case "stop": + sharedDefaults()?.set(false, forKey: "recordingRequested") + // Primary stop: drop a flag file in the shared App Group container that the + // extension polls on every frame (reliable cross-process). Backup: a Darwin + // notification (immediate, but delivery to broadcast extensions is flaky). + writeStopFlag() + NSLog("ScreenRecorderPlugin: posting stop notification %@", ScreenRecorderPlugin.stopNotificationName) + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(ScreenRecorderPlugin.stopNotificationName as CFString), + nil, nil, true) + // The extension writes the final output path into shared defaults when it finishes. + let path = sharedDefaults()?.string(forKey: "lastRecordingPath") + result(path) + default: + result(FlutterMethodNotImplemented) + } + } + + private func sharedDefaults() -> UserDefaults? { + UserDefaults(suiteName: ScreenRecorderPlugin.appGroup) + } + + private func appGroupContainer() -> URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: ScreenRecorderPlugin.appGroup) + } + + private func stopFlagURL() -> URL? { + appGroupContainer()?.appendingPathComponent("screen_recorder_stop") + } + + private func clearStopFlag() { + if let url = stopFlagURL() { try? FileManager.default.removeItem(at: url) } + } + + private func writeStopFlag() { + guard let container = appGroupContainer(), let stop = stopFlagURL() else { + NSLog("ScreenRecorderPlugin: WARNING no App Group container in app — is %@ enabled on the Runner target?", + ScreenRecorderPlugin.appGroup) + return + } + FileManager.default.createFile(atPath: stop.path, contents: Data()) + // Heartbeat: the extension writes this file in broadcastStarted. If it's MISSING, + // the extension isn't sharing the App Group (capability not enabled on the extension + // target) or isn't running our SampleHandler — so it can never be stopped/saved. + let alive = FileManager.default.fileExists(atPath: container.appendingPathComponent("screen_recorder_active").path) + NSLog("ScreenRecorderPlugin: stop flag written; extension heartbeat = %@", + alive ? "ALIVE" : "MISSING (App Group not shared with extension, or extension not running our code)") + } + + private func presentBroadcastPicker() { + DispatchQueue.main.async { + let picker = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) + // Preselect our extension so the user's tap targets it immediately. + picker.preferredExtension = ScreenRecorderPlugin.extensionBundleId + picker.showsMicrophoneButton = false + // Add to the key window so the picker can present its sheet. + if let windowScene = UIApplication.shared.connectedScenes + .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) { + window.addSubview(picker) + } + // Programmatically tap the picker button — the user still sees and confirms the sheet. + for subview in picker.subviews { + if let button = subview as? UIButton { + button.sendActions(for: .touchUpInside) + } + } + // The picker is only a host for the trigger; once the system sheet is up + // it's no longer needed. Remove it so a new 1×1 view doesn't accumulate on + // the key window on every recording start. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + picker.removeFromSuperview() + } + } + } +} diff --git a/packages/screen_recorder/ios/screen_recorder.podspec b/packages/screen_recorder/ios/screen_recorder.podspec new file mode 100644 index 00000000..163a27e5 --- /dev/null +++ b/packages/screen_recorder/ios/screen_recorder.podspec @@ -0,0 +1,15 @@ +Pod::Spec.new do |s| + s.name = 'screen_recorder' + s.version = '0.0.1' + s.summary = 'In-repo screen recorder.' + s.description = 'iOS ReplayKit broadcast bridge.' + s.homepage = 'https://bikecontrol.app' + s.license = { :type => 'Proprietary' } + s.author = { 'BikeControl' => 'jonas@bikecontrol.app' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '15.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' +end diff --git a/packages/screen_recorder/lib/screen_recorder.dart b/packages/screen_recorder/lib/screen_recorder.dart new file mode 100644 index 00000000..673e07d0 --- /dev/null +++ b/packages/screen_recorder/lib/screen_recorder.dart @@ -0,0 +1,36 @@ +import 'package:flutter/services.dart'; + +/// Thin method-channel wrapper over the native (iOS/macOS/Windows) recorders. +/// All methods return defensively: failures surface as `false`/`null`, never throw. +class ScreenRecorderChannel { + static const MethodChannel _channel = MethodChannel('screen_recorder'); + + /// Whether the current OS build supports capture (macOS >= 12.3, WGC available, etc.). + Future isSupported() async { + final result = await _channel.invokeMethod('isSupported'); + return result ?? false; + } + + /// True if capture permission is already granted (macOS TCC / iOS n/a → true). + Future hasPermission() async { + final result = await _channel.invokeMethod('hasPermission'); + return result ?? false; + } + + /// Request capture permission. Returns true if granted. + Future requestPermission() async { + final result = await _channel.invokeMethod('requestPermission'); + return result ?? false; + } + + /// Begin recording. Returns true if recording started. + Future start() async { + final result = await _channel.invokeMethod('start'); + return result ?? false; + } + + /// Stop recording. Returns the saved file path, or null on failure. + Future stop() async { + return _channel.invokeMethod('stop'); + } +} diff --git a/packages/screen_recorder/macos/Classes/ScreenCaptureRecorder.swift b/packages/screen_recorder/macos/Classes/ScreenCaptureRecorder.swift new file mode 100644 index 00000000..faa8771c --- /dev/null +++ b/packages/screen_recorder/macos/Classes/ScreenCaptureRecorder.swift @@ -0,0 +1,130 @@ +import Foundation +import AVFoundation +import CoreGraphics +@preconcurrency import ScreenCaptureKit + +/// Records the main display to an mp4 using ScreenCaptureKit + AVAssetWriter. +@available(macOS 12.3, *) +final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { + private var stream: SCStream? + private var writer: AVAssetWriter? + private var videoInput: AVAssetWriterInput? + private var sessionStarted = false + private var outputURL: URL? + + func start() async throws { + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) + guard let display = content.displays.first else { + throw NSError(domain: "screen_recorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "No display"]) + } + + // Fix 1 (Retina): capture at native pixels via CGDisplayMode. + let mode = CGDisplayCopyDisplayMode(display.displayID) + let pixelWidth = mode?.pixelWidth ?? display.width + let pixelHeight = mode?.pixelHeight ?? display.height + + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Movies/BikeControl", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("BikeControl_\(Int(Date().timeIntervalSince1970)).mp4") + self.outputURL = url + + let writer = try AVAssetWriter(outputURL: url, fileType: .mp4) + let settings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: pixelWidth, + AVVideoHeightKey: pixelHeight, + ] + let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) + input.expectsMediaDataInRealTime = true + writer.add(input) + self.writer = writer + self.videoInput = input + + let filter = SCContentFilter(display: display, excludingWindows: []) + let config = SCStreamConfiguration() + // Fix 1 (Retina): set stream dimensions to native pixel dimensions + config.width = pixelWidth + config.height = pixelHeight + config.minimumFrameInterval = CMTime(value: 1, timescale: 30) + config.queueDepth = 5 + config.pixelFormat = kCVPixelFormatType_32BGRA + + // Fix 5 (stream errors): pass self as delegate to observe mid-session failures + let stream = SCStream(filter: filter, configuration: config, delegate: self) + try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: DispatchQueue(label: "screen_recorder.capture")) + self.stream = stream + + // Fix 3 (cleanup on throw): cancel writer if start() throws after writer creation + var started = false + defer { + if !started { + writer.cancelWriting() + self.writer = nil + self.videoInput = nil + self.stream = nil + } + } + + // Fix 2 (startWriting): guard the Bool return value + guard writer.startWriting() else { + throw writer.error ?? NSError(domain: "screen_recorder", code: 2, userInfo: [NSLocalizedDescriptionKey: "AVAssetWriter failed to start"]) + } + + try await stream.startCapture() + started = true + } + + func stop() async -> String? { + guard let stream = stream else { return nil } + + // Fix 4 (stop-before-first-frame): cancel instead of finalize if no frames were written + if !sessionStarted { + try? await stream.stopCapture() + self.stream = nil + writer?.cancelWriting() + writer = nil + videoInput = nil + return nil + } + + try? await stream.stopCapture() + self.stream = nil + videoInput?.markAsFinished() + await writer?.finishWriting() + + // Fix 4 (stop finalize): only return path if finalization succeeded + let status = writer?.status + let path = status == .completed ? outputURL?.path : nil + if status != .completed { + NSLog("screen_recorder: finishWriting status=\(String(describing: status)) err=\(String(describing: writer?.error))") + } + + writer = nil + videoInput = nil + sessionStarted = false + return path + } + + // SCStreamOutput + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { + guard type == .screen, sampleBuffer.isValid, + let writer = writer, let input = videoInput else { return } + guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]], + let statusRaw = attachments.first?[.status] as? Int, + let status = SCFrameStatus(rawValue: statusRaw), status == .complete else { return } + + if !sessionStarted { + writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) + sessionStarted = true + } + if input.isReadyForMoreMediaData { + input.append(sampleBuffer) + } + } + + // Fix 5 (stream errors): SCStreamDelegate — log mid-session stream errors + func stream(_ stream: SCStream, didStopWithError error: Error) { + NSLog("screen_recorder: SCStream stopped with error: \(error)") + } +} diff --git a/packages/screen_recorder/macos/Classes/ScreenRecorderPlugin.swift b/packages/screen_recorder/macos/Classes/ScreenRecorderPlugin.swift new file mode 100644 index 00000000..4390c155 --- /dev/null +++ b/packages/screen_recorder/macos/Classes/ScreenRecorderPlugin.swift @@ -0,0 +1,57 @@ +import Cocoa +import FlutterMacOS +import CoreGraphics + +public class ScreenRecorderPlugin: NSObject, FlutterPlugin { + private var recorder: AnyObject? + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "screen_recorder", binaryMessenger: registrar.messenger) + registrar.addMethodCallDelegate(ScreenRecorderPlugin(), channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "isSupported": + if #available(macOS 12.3, *) { result(true) } else { result(false) } + case "hasPermission": + result(CGPreflightScreenCaptureAccess()) + case "requestPermission": + result(CGRequestScreenCaptureAccess()) + case "start": + if #available(macOS 12.3, *) { + // Fix 6 (double-start leak): guard against starting a second recorder + guard self.recorder == nil else { result(false); return } + let rec = ScreenCaptureRecorder() + self.recorder = rec + Task { + do { + try await rec.start() + // Fix 7 (main thread): call FlutterResult on the main thread + DispatchQueue.main.async { result(true) } + } catch { + NSLog("screen_recorder start error: \(error)") + // Reset so a failed start doesn't leave `recorder` non-nil, which + // would trip the `guard recorder == nil` on every later start + // (recording stays dead until app relaunch, since only stop() nils it). + DispatchQueue.main.async { + self.recorder = nil + result(false) + } + } + } + } else { result(false) } + case "stop": + if #available(macOS 12.3, *), let rec = self.recorder as? ScreenCaptureRecorder { + Task { + let path = await rec.stop() + self.recorder = nil + // Fix 7 (main thread): call FlutterResult on the main thread + DispatchQueue.main.async { result(path) } + } + } else { result(nil) } + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/packages/screen_recorder/macos/screen_recorder.podspec b/packages/screen_recorder/macos/screen_recorder.podspec new file mode 100644 index 00000000..fc847ab8 --- /dev/null +++ b/packages/screen_recorder/macos/screen_recorder.podspec @@ -0,0 +1,15 @@ +Pod::Spec.new do |s| + s.name = 'screen_recorder' + s.version = '0.0.1' + s.summary = 'In-repo screen recorder.' + s.description = 'macOS ScreenCaptureKit screen recording.' + s.homepage = 'https://bikecontrol.app' + s.license = { :type => 'Proprietary' } + s.author = { 'BikeControl' => 'jonas@bikecontrol.app' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + s.platform = :osx, '12.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' +end diff --git a/packages/screen_recorder/pubspec.yaml b/packages/screen_recorder/pubspec.yaml new file mode 100644 index 00000000..eddfa66a --- /dev/null +++ b/packages/screen_recorder/pubspec.yaml @@ -0,0 +1,27 @@ +name: screen_recorder +description: "In-repo screen recording plugin for BikeControl (iOS/macOS/Windows capture)." +version: 0.0.1 +publish_to: none + +environment: + sdk: '>=3.8.1 <4.0.0' + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + plugin: + platforms: + ios: + pluginClass: ScreenRecorderPlugin + macos: + pluginClass: ScreenRecorderPlugin + windows: + pluginClass: ScreenRecorderPlugin diff --git a/packages/screen_recorder/windows/CMakeLists.txt b/packages/screen_recorder/windows/CMakeLists.txt new file mode 100644 index 00000000..23e1e4aa --- /dev/null +++ b/packages/screen_recorder/windows/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.14) +set(PROJECT_NAME "screen_recorder") +project(${PROJECT_NAME} LANGUAGES CXX) + +set(PLUGIN_NAME "screen_recorder_plugin") + +add_library(${PLUGIN_NAME} SHARED + "screen_recorder_plugin.cpp" + "screen_recorder_plugin_c_api.cpp" + "capture_recorder.cpp" +) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_STANDARD 17) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin + d3d11 dxgi mf mfplat mfreadwrite mfuuid windowsapp) + +set(screen_recorder_bundled_libraries "" PARENT_SCOPE) diff --git a/packages/screen_recorder/windows/capture_recorder.cpp b/packages/screen_recorder/windows/capture_recorder.cpp new file mode 100644 index 00000000..20d6511d --- /dev/null +++ b/packages/screen_recorder/windows/capture_recorder.cpp @@ -0,0 +1,317 @@ +#include "capture_recorder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "windowsapp") + +using namespace winrt; +using namespace winrt::Windows::Graphics::Capture; +using namespace winrt::Windows::Graphics::DirectX; +using namespace winrt::Windows::Graphics::DirectX::Direct3D11; + +namespace screen_recorder { + +namespace { +std::wstring VideosDir() { + PWSTR path = nullptr; + std::wstring dir; + if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Videos, 0, nullptr, &path))) { + dir = path; + CoTaskMemFree(path); + } + dir += L"\\BikeControl"; + CreateDirectoryW(dir.c_str(), nullptr); + return dir; +} +} // namespace + +struct CaptureRecorder::Impl { + com_ptr d3dDevice; + com_ptr d3dContext; + IDirect3DDevice winrtDevice{nullptr}; + GraphicsCaptureItem item{nullptr}; + Direct3D11CaptureFramePool framePool{nullptr}; + GraphicsCaptureSession session{nullptr}; + Direct3D11CaptureFramePool::FrameArrived_revoker frameArrived; + + com_ptr sinkWriter; + DWORD streamIndex = 0; + std::atomic running{false}; + LONGLONG startQpc = 0; + LARGE_INTEGER qpcFrequency{}; + UINT width = 0, height = 0; + std::wstring outPath; + + // Staging texture reused across frames (recreated if size changes). + com_ptr stagingTexture; +}; + +bool CaptureRecorder::IsSupported() { + try { + return GraphicsCaptureSession::IsSupported(); + } catch (...) { return false; } +} + +bool CaptureRecorder::Start() { + try { + impl_ = new Impl(); + + // 1) D3D11 device with BGRA support (required by WGC). + D3D_FEATURE_LEVEL fl; + if (FAILED(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, nullptr, 0, D3D11_SDK_VERSION, + impl_->d3dDevice.put(), &fl, impl_->d3dContext.put()))) { + return false; + } + com_ptr dxgiDevice = impl_->d3dDevice.as(); + com_ptr<::IInspectable> inspectable; + // VERIFY on Windows: CreateDirect3D11DeviceFromDXGIDevice is in + // and links via windowsapp. + CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.get(), inspectable.put()); + impl_->winrtDevice = inspectable.as(); + + // 2) Capture item for the primary monitor. + HMONITOR mon = MonitorFromWindow(GetDesktopWindow(), MONITOR_DEFAULTTOPRIMARY); + auto interop = get_activation_factory(); + check_hresult(interop->CreateForMonitor(mon, + guid_of(), put_abi(impl_->item))); + auto size = impl_->item.Size(); + impl_->width = static_cast(size.Width); + impl_->height = static_cast(size.Height); + + // 3) Media Foundation sink writer (H.264 mp4). + MFStartup(MF_VERSION); + impl_->outPath = VideosDir() + L"\\BikeControl_" + + std::to_wstring(std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()) + L".mp4"; + com_ptr writer; + check_hresult(MFCreateSinkWriterFromURL(impl_->outPath.c_str(), nullptr, nullptr, writer.put())); + + // Output type: H.264 + com_ptr outType; + MFCreateMediaType(outType.put()); + outType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + outType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264); + outType->SetUINT32(MF_MT_AVG_BITRATE, 8000000); + outType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + MFSetAttributeSize(outType.get(), MF_MT_FRAME_SIZE, impl_->width, impl_->height); + MFSetAttributeRatio(outType.get(), MF_MT_FRAME_RATE, 30, 1); + MFSetAttributeRatio(outType.get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); + writer->AddStream(outType.get(), &impl_->streamIndex); + + // Input type: RGB32 (= BGRA from WGC; MF treats RGB32 as BGRA on Windows). + com_ptr inType; + MFCreateMediaType(inType.put()); + inType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + inType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32); // BGRA from WGC + inType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + MFSetAttributeSize(inType.get(), MF_MT_FRAME_SIZE, impl_->width, impl_->height); + MFSetAttributeRatio(inType.get(), MF_MT_FRAME_RATE, 30, 1); + // VERIFY on Windows: MF_MT_DEFAULT_STRIDE for top-down RGB32 is positive + // (width * 4). WGC surfaces are top-down, so this is correct. + // VERIFY on Windows: Some H.264 encoders require even dimensions; ensure + // width/height are even (round down if needed). + MFSetAttributeSize(inType.get(), MF_MT_FRAME_SIZE, impl_->width, impl_->height); + writer->SetInputMediaType(impl_->streamIndex, inType.get(), nullptr); + writer->BeginWriting(); + impl_->sinkWriter = writer; + + // Record the QPC start time and frequency for sample timestamps. + QueryPerformanceFrequency(&impl_->qpcFrequency); + QueryPerformanceCounter(reinterpret_cast(&impl_->startQpc)); + + // 4) Frame pool + session. + impl_->framePool = Direct3D11CaptureFramePool::Create( + impl_->winrtDevice, DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, impl_->item.Size()); + impl_->session = impl_->framePool.CreateCaptureSession(impl_->item); + impl_->running = true; + + // ----------------------------------------------------------------------- + // FrameArrived: D3D texture → staging copy → CPU map → IMFSample → write + // + // NOTE: This is the part most likely to need iteration on-device. + // The overall flow is correct per MSDN; exact error codes and any + // quirks of the H.264 encoder (stride alignment, colorspace hints) + // may need adjustment. See VERIFY comments inline. + // ----------------------------------------------------------------------- + impl_->frameArrived = impl_->framePool.FrameArrived(auto_revoke, + [this](Direct3D11CaptureFramePool const& pool, auto const&) { + auto frame = pool.TryGetNextFrame(); + if (!frame || !impl_->running) return; + + // --- Step A: Get the ID3D11Texture2D from the WGC frame surface --- + // + // frame.Surface() is an IDirect3DSurface (WinRT). To get the + // underlying D3D11 texture, QI for IDirect3DDxgiInterfaceAccess. + // VERIFY on Windows: IDirect3DDxgiInterfaceAccess is in + // . + auto surface = frame.Surface(); + com_ptr frameTexture; + { + // IDirect3DDxgiInterfaceAccess lives in the ABI namespace + // ::Windows::Graphics::DirectX::Direct3D11 (declared by + // ), NOT the winrt + // projection namespace brought in by `using namespace` above. It must + // be fully qualified with a leading :: so `using namespace winrt;` + // doesn't misroute it to a non-existent winrt::Windows::... type. + auto dxgiAccess = surface.as< + ::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>(); + HRESULT hr = dxgiAccess->GetInterface(IID_PPV_ARGS(frameTexture.put())); + if (FAILED(hr)) return; + } + + // --- Step B: Describe the source texture and create/reuse a staging texture --- + D3D11_TEXTURE2D_DESC srcDesc{}; + frameTexture->GetDesc(&srcDesc); + + // Reuse staging texture if dimensions match; otherwise (re)create. + bool needNewStaging = true; + if (impl_->stagingTexture) { + D3D11_TEXTURE2D_DESC stgDesc{}; + impl_->stagingTexture->GetDesc(&stgDesc); + needNewStaging = (stgDesc.Width != srcDesc.Width || + stgDesc.Height != srcDesc.Height); + } + if (needNewStaging) { + impl_->stagingTexture = nullptr; + D3D11_TEXTURE2D_DESC stgDesc{}; + stgDesc.Width = srcDesc.Width; + stgDesc.Height = srcDesc.Height; + stgDesc.MipLevels = 1; + stgDesc.ArraySize = 1; + stgDesc.Format = srcDesc.Format; // DXGI_FORMAT_B8G8R8A8_UNORM + stgDesc.SampleDesc.Count = 1; + stgDesc.SampleDesc.Quality = 0; + stgDesc.Usage = D3D11_USAGE_STAGING; + stgDesc.BindFlags = 0; + stgDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + stgDesc.MiscFlags = 0; + HRESULT hr = impl_->d3dDevice->CreateTexture2D( + &stgDesc, nullptr, impl_->stagingTexture.put()); + if (FAILED(hr)) return; + } + + // --- Step C: CopyResource GPU→staging --- + impl_->d3dContext->CopyResource(impl_->stagingTexture.get(), frameTexture.get()); + + // --- Step D: Map the staging texture for CPU read --- + D3D11_MAPPED_SUBRESOURCE mapped{}; + HRESULT hrMap = impl_->d3dContext->Map( + impl_->stagingTexture.get(), 0, D3D11_MAP_READ, 0, &mapped); + if (FAILED(hrMap)) return; + + // --- Step E: Build IMFSample from the BGRA bytes --- + const UINT frameWidth = srcDesc.Width; + const UINT frameHeight = srcDesc.Height; + // BGRA: 4 bytes per pixel, top-down (positive stride = width*4 contiguous). + const DWORD bytesPerRow = frameWidth * 4; + const DWORD totalBytes = bytesPerRow * frameHeight; + + com_ptr mfBuffer; + HRESULT hr = MFCreateMemoryBuffer(totalBytes, mfBuffer.put()); + if (FAILED(hr)) { + impl_->d3dContext->Unmap(impl_->stagingTexture.get(), 0); + return; + } + + BYTE* pDst = nullptr; + DWORD maxLen = 0, curLen = 0; + hr = mfBuffer->Lock(&pDst, &maxLen, &curLen); + if (FAILED(hr)) { + impl_->d3dContext->Unmap(impl_->stagingTexture.get(), 0); + return; + } + + // Copy row-by-row in case the GPU stride (RowPitch) > bytesPerRow. + // + // Orientation: WGC/D3D surfaces are top-down (row 0 = top), but the MF + // SinkWriter's RGB->NV12 conversion treats uncompressed RGB32 as + // bottom-up (legacy DIB convention) when no stride sign says otherwise. + // Feeding our top-down buffer straight through produced an upside-down + // recording. We cancel that fixed vertical flip by writing each source + // row into the mirrored destination row, i.e. handing MF a bottom-up + // buffer. Same number of memcpys, so no extra cost. + const BYTE* pSrc = static_cast(mapped.pData); + for (UINT row = 0; row < frameHeight; ++row) { + memcpy(pDst + (frameHeight - 1 - row) * bytesPerRow, + pSrc + row * mapped.RowPitch, + bytesPerRow); + } + mfBuffer->Unlock(); + mfBuffer->SetCurrentLength(totalBytes); + + impl_->d3dContext->Unmap(impl_->stagingTexture.get(), 0); + + // --- Step F: Create the IMFSample, set time and duration, write --- + com_ptr sample; + hr = MFCreateSample(sample.put()); + if (FAILED(hr)) return; + + sample->AddBuffer(mfBuffer.get()); + + // Compute sample time in 100ns units from QPC. + LARGE_INTEGER nowQpc{}; + QueryPerformanceCounter(&nowQpc); + LONGLONG elapsedQpc = nowQpc.QuadPart - impl_->startQpc; + // Convert QPC ticks → 100ns units: (ticks * 10,000,000) / freq + // VERIFY on Windows: integer overflow possible for very long recordings; + // use MFllMulDiv if available, or promote to __int128 / use double. + LONGLONG sampleTime = (elapsedQpc * 10000000LL) / impl_->qpcFrequency.QuadPart; + // Duration for 30 fps = 1/30 s = 333333 100ns units. + LONGLONG sampleDuration = 333333LL; + + sample->SetSampleTime(sampleTime); + sample->SetSampleDuration(sampleDuration); + + // VERIFY on Windows: WriteSample must be called from the same thread + // that called BeginWriting (or from any thread if the writer was created + // with MF_SINK_WRITER_ASYNC_CALLBACK). Here we call it inline from the + // WGC dispatcher thread; this is typically fine for the synchronous writer. + if (impl_->sinkWriter && impl_->running) { + impl_->sinkWriter->WriteSample(impl_->streamIndex, sample.get()); + } + }); + + impl_->session.StartCapture(); + return true; + } catch (...) { + return false; + } +} + +std::string CaptureRecorder::Stop() { + if (!impl_) return ""; + impl_->running = false; + try { + impl_->frameArrived.revoke(); + if (impl_->session) impl_->session.Close(); + if (impl_->framePool) impl_->framePool.Close(); + impl_->stagingTexture = nullptr; + if (impl_->sinkWriter) impl_->sinkWriter->Finalize(); + MFShutdown(); + } catch (...) {} + std::wstring w = impl_->outPath; + delete impl_; impl_ = nullptr; + int len = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, nullptr, 0, nullptr, nullptr); + std::string out(len > 0 ? len - 1 : 0, '\0'); + if (len > 0) WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, out.data(), len, nullptr, nullptr); + return out; +} + +CaptureRecorder::~CaptureRecorder() { if (impl_) { Stop(); } } +} // namespace screen_recorder diff --git a/packages/screen_recorder/windows/capture_recorder.h b/packages/screen_recorder/windows/capture_recorder.h new file mode 100644 index 00000000..3068b47c --- /dev/null +++ b/packages/screen_recorder/windows/capture_recorder.h @@ -0,0 +1,19 @@ +#ifndef SCREEN_RECORDER_CAPTURE_RECORDER_H_ +#define SCREEN_RECORDER_CAPTURE_RECORDER_H_ +#include + +namespace screen_recorder { +// Captures the primary monitor via Windows.Graphics.Capture and encodes to +// H.264 mp4 in the user's Videos\BikeControl folder via Media Foundation. +class CaptureRecorder { + public: + static bool IsSupported(); // GraphicsCaptureSession::IsSupported() + bool Start(); // returns true if capture started + std::string Stop(); // returns saved path, or "" on failure + ~CaptureRecorder(); + private: + struct Impl; + Impl* impl_ = nullptr; +}; +} // namespace screen_recorder +#endif diff --git a/packages/screen_recorder/windows/include/screen_recorder/screen_recorder_plugin.h b/packages/screen_recorder/windows/include/screen_recorder/screen_recorder_plugin.h new file mode 100644 index 00000000..0a42b6fb --- /dev/null +++ b/packages/screen_recorder/windows/include/screen_recorder/screen_recorder_plugin.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_SCREEN_RECORDER_PLUGIN_H_PUBLIC_ +#define FLUTTER_PLUGIN_SCREEN_RECORDER_PLUGIN_H_PUBLIC_ +// Public header included by Flutter's generated_plugin_registrant.cc. +// Exposes the C-linkage registration entry point only. +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void ScreenRecorderPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} +#endif +#endif // FLUTTER_PLUGIN_SCREEN_RECORDER_PLUGIN_H_PUBLIC_ diff --git a/packages/screen_recorder/windows/include/screen_recorder/screen_recorder_plugin_c_api.h b/packages/screen_recorder/windows/include/screen_recorder/screen_recorder_plugin_c_api.h new file mode 100644 index 00000000..3258aead --- /dev/null +++ b/packages/screen_recorder/windows/include/screen_recorder/screen_recorder_plugin_c_api.h @@ -0,0 +1,17 @@ +#ifndef FLUTTER_PLUGIN_SCREEN_RECORDER_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_SCREEN_RECORDER_PLUGIN_C_API_H_ +#include +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif +#if defined(__cplusplus) +extern "C" { +#endif +FLUTTER_PLUGIN_EXPORT void ScreenRecorderPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); +#if defined(__cplusplus) +} +#endif +#endif diff --git a/packages/screen_recorder/windows/screen_recorder_plugin.cpp b/packages/screen_recorder/windows/screen_recorder_plugin.cpp new file mode 100644 index 00000000..8d476468 --- /dev/null +++ b/packages/screen_recorder/windows/screen_recorder_plugin.cpp @@ -0,0 +1,48 @@ +#include "screen_recorder_plugin.h" +#include +#include +#include + +namespace screen_recorder { + +void ScreenRecorderPlugin::RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar) { + auto channel = std::make_unique>( + registrar->messenger(), "screen_recorder", + &flutter::StandardMethodCodec::GetInstance()); + auto plugin = std::make_unique(); + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + registrar->AddPlugin(std::move(plugin)); +} + +ScreenRecorderPlugin::ScreenRecorderPlugin() {} +ScreenRecorderPlugin::~ScreenRecorderPlugin() {} + +void ScreenRecorderPlugin::HandleMethodCall( + const flutter::MethodCall& call, + std::unique_ptr> result) { + const std::string& method = call.method_name(); + if (method == "isSupported") { + result->Success(flutter::EncodableValue(CaptureRecorder::IsSupported())); + } else if (method == "hasPermission" || method == "requestPermission") { + result->Success(flutter::EncodableValue(true)); // WGC needs no prompt + } else if (method == "start") { + recorder_ = std::make_unique(); + bool ok = recorder_->Start(); + result->Success(flutter::EncodableValue(ok)); + } else if (method == "stop") { + if (recorder_) { + std::string path = recorder_->Stop(); + recorder_.reset(); + if (path.empty()) result->Success(flutter::EncodableValue()); + else result->Success(flutter::EncodableValue(path)); + } else { + result->Success(flutter::EncodableValue()); + } + } else { + result->NotImplemented(); + } +} +} // namespace screen_recorder diff --git a/packages/screen_recorder/windows/screen_recorder_plugin.h b/packages/screen_recorder/windows/screen_recorder_plugin.h new file mode 100644 index 00000000..b1d72e4b --- /dev/null +++ b/packages/screen_recorder/windows/screen_recorder_plugin.h @@ -0,0 +1,20 @@ +#ifndef FLUTTER_PLUGIN_SCREEN_RECORDER_PLUGIN_H_ +#define FLUTTER_PLUGIN_SCREEN_RECORDER_PLUGIN_H_ +#include +#include +#include +#include "capture_recorder.h" + +namespace screen_recorder { +class ScreenRecorderPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + ScreenRecorderPlugin(); + virtual ~ScreenRecorderPlugin(); + private: + void HandleMethodCall(const flutter::MethodCall& call, + std::unique_ptr> result); + std::unique_ptr recorder_; +}; +} // namespace screen_recorder +#endif diff --git a/packages/screen_recorder/windows/screen_recorder_plugin_c_api.cpp b/packages/screen_recorder/windows/screen_recorder_plugin_c_api.cpp new file mode 100644 index 00000000..b0270e95 --- /dev/null +++ b/packages/screen_recorder/windows/screen_recorder_plugin_c_api.cpp @@ -0,0 +1,19 @@ +#include "include/screen_recorder/screen_recorder_plugin_c_api.h" +#include "include/screen_recorder/screen_recorder_plugin.h" +#include +#include "screen_recorder_plugin.h" + +// Called by Flutter's generated_plugin_registrant.cc (pluginClass without CApi suffix). +void ScreenRecorderPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + screen_recorder::ScreenRecorderPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} + +// Legacy CApi-suffixed alias; kept for compatibility if pubspec pluginClass +// is ever changed to ScreenRecorderPluginCApi. +void ScreenRecorderPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + ScreenRecorderPluginRegisterWithRegistrar(registrar); +} diff --git a/prop b/prop index d167f334..a1959b13 160000 --- a/prop +++ b/prop @@ -1 +1 @@ -Subproject commit d167f334035c36620cc86f2b3ef2162f6dc3a16f +Subproject commit a1959b131def50f2a5ce39834b504e80ac790ab7 diff --git a/pubspec.lock b/pubspec.lock index 7d0680a9..996d34af 100755 --- a/pubspec.lock +++ b/pubspec.lock @@ -427,6 +427,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_foreground_task: + dependency: transitive + description: + name: flutter_foreground_task + sha256: fc5c01a5e1b8f7bb51d0c737714f0c50440dbdf1aeddc5f8cbba313aa6fd4856 + url: "https://pub.dev" + source: hosted + version: "9.2.2" flutter_lints: dependency: "direct dev" description: @@ -520,6 +528,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + flutter_screen_recording: + dependency: "direct main" + description: + name: flutter_screen_recording + sha256: "0fb52011aa11ac4dc3e89bea3aaee4a709321af374b3b318e74fa8ed8338412e" + url: "https://pub.dev" + source: hosted + version: "2.0.25" + flutter_screen_recording_platform_interface: + dependency: transitive + description: + name: flutter_screen_recording_platform_interface + sha256: "9e07f78a2529bc5b6c77d045b392ed4062445543613fcad431c4cc1da57558d7" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + flutter_screen_recording_web: + dependency: transitive + description: + name: flutter_screen_recording_web + sha256: bb19b3875e0d8ddfe009ed42086339f8016df05e685c37ff06cb56e165f2f7fa + url: "https://pub.dev" + source: hosted + version: "1.0.8" flutter_secure_storage: dependency: "direct main" description: @@ -623,6 +655,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + gal: + dependency: "direct main" + description: + name: gal + sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee" + url: "https://pub.dev" + source: hosted + version: "2.3.2" gamepads: dependency: "direct main" description: @@ -675,10 +715,10 @@ packages: dependency: transitive description: name: gamepads_web - sha256: bf198f958727cd2d96f19be8b8d49057c9eb6b3095728d794181362b740d69ac + sha256: "860eb4f11fdb6dfcf18f793a491282747007909075d161373df7f067dfddb9a4" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.1+1" gamepads_windows: dependency: transitive description: @@ -968,18 +1008,10 @@ packages: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - js_interop: - dependency: transitive - description: - name: js_interop - sha256: "7ec859c296958ccea34dc770504bd3ff4ae52fdd9e7eeb2bacc7081ad476a1f5" + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.0.1" + version: "0.7.2" json_annotation: dependency: transitive description: @@ -1553,6 +1585,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + screen_recorder: + dependency: "direct main" + description: + path: "packages/screen_recorder" + relative: true + source: path + version: "0.0.1" screen_retriever: dependency: transitive description: @@ -1883,10 +1922,10 @@ packages: description: path: "." ref: changes - resolved-ref: "3beb8b6d18ac857fba578416857455d55aa788f9" + resolved-ref: d8961d78cb733526915eeb8a0530218a88d5649b url: "https://github.com/jonasbark/universal_ble.git" source: git - version: "2.0.4" + version: "2.1.0" url_launcher: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 99dba51e..fdb49330 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: bike_control description: "BikeControl - Control your virtual riding" publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 6.1.0+138 +version: 6.2.0+142 environment: sdk: ^3.9.0 @@ -46,6 +46,10 @@ dependencies: image_picker: ^1.1.2 file_picker: ^10.3.10 flutter_screen_capture: ^2.0.1 + flutter_screen_recording: ^2.0.25 + gal: ^2.3.1 + screen_recorder: + path: packages/screen_recorder in_app_review: ^2.0.11 flutter_secure_storage: ^10.0.0 in_app_purchase: ^3.2.1 @@ -147,4 +151,4 @@ msix_config: logo_path: web/icons/Icon-512.png build_windows: false protocol_activation: https, bikecontrol - capabilities: internetClient,bluetooth,inputInjectionBrokered + capabilities: internetClient,bluetooth,inputInjectionBrokered,videosLibrary diff --git a/screenshots/de/customization-android-1320x2868.png b/screenshots/de/customization-android-1320x2868.png index 48411d1d..b69269d9 100644 Binary files a/screenshots/de/customization-android-1320x2868.png and b/screenshots/de/customization-android-1320x2868.png differ diff --git a/screenshots/de/customization-androidTablet-3840x2400.png b/screenshots/de/customization-androidTablet-3840x2400.png index 63ccc08c..355a9fd2 100644 Binary files a/screenshots/de/customization-androidTablet-3840x2400.png and b/screenshots/de/customization-androidTablet-3840x2400.png differ diff --git a/screenshots/de/customization-desktop-2560x1600.png b/screenshots/de/customization-desktop-2560x1600.png index 957c5fe2..ea761d87 100644 Binary files a/screenshots/de/customization-desktop-2560x1600.png and b/screenshots/de/customization-desktop-2560x1600.png differ diff --git a/screenshots/de/customization-iPad-2752x2064.png b/screenshots/de/customization-iPad-2752x2064.png index 212e3b15..6fb52ca6 100644 Binary files a/screenshots/de/customization-iPad-2752x2064.png and b/screenshots/de/customization-iPad-2752x2064.png differ diff --git a/screenshots/de/customization-iPhone-1242x2688.png b/screenshots/de/customization-iPhone-1242x2688.png index bdb4aae3..3d71b85f 100644 Binary files a/screenshots/de/customization-iPhone-1242x2688.png and b/screenshots/de/customization-iPhone-1242x2688.png differ diff --git a/screenshots/de/customization-noFrame-1100x2390.png b/screenshots/de/customization-noFrame-1100x2390.png index a84208f7..0fba020d 100644 Binary files a/screenshots/de/customization-noFrame-1100x2390.png and b/screenshots/de/customization-noFrame-1100x2390.png differ diff --git a/screenshots/en/customization-android-1320x2868.png b/screenshots/en/customization-android-1320x2868.png index 48294ab3..0b4b1755 100644 Binary files a/screenshots/en/customization-android-1320x2868.png and b/screenshots/en/customization-android-1320x2868.png differ diff --git a/screenshots/en/customization-androidTablet-3840x2400.png b/screenshots/en/customization-androidTablet-3840x2400.png index c012fc5a..2928025d 100644 Binary files a/screenshots/en/customization-androidTablet-3840x2400.png and b/screenshots/en/customization-androidTablet-3840x2400.png differ diff --git a/screenshots/en/customization-desktop-2560x1600.png b/screenshots/en/customization-desktop-2560x1600.png index 5f7c230f..b3d9365a 100644 Binary files a/screenshots/en/customization-desktop-2560x1600.png and b/screenshots/en/customization-desktop-2560x1600.png differ diff --git a/screenshots/en/customization-iPad-2752x2064.png b/screenshots/en/customization-iPad-2752x2064.png index d5831814..c1861cf5 100644 Binary files a/screenshots/en/customization-iPad-2752x2064.png and b/screenshots/en/customization-iPad-2752x2064.png differ diff --git a/screenshots/en/customization-iPhone-1242x2688.png b/screenshots/en/customization-iPhone-1242x2688.png index f2332a70..2d9a4877 100644 Binary files a/screenshots/en/customization-iPhone-1242x2688.png and b/screenshots/en/customization-iPhone-1242x2688.png differ diff --git a/screenshots/en/customization-noFrame-1100x2390.png b/screenshots/en/customization-noFrame-1100x2390.png index b9ac4618..390c3c12 100644 Binary files a/screenshots/en/customization-noFrame-1100x2390.png and b/screenshots/en/customization-noFrame-1100x2390.png differ diff --git a/screenshots/en/frontderailleur-gear.png b/screenshots/en/frontderailleur-gear.png new file mode 100644 index 00000000..82a90fa2 Binary files /dev/null and b/screenshots/en/frontderailleur-gear.png differ diff --git a/screenshots/en/frontderailleur-setting.png b/screenshots/en/frontderailleur-setting.png new file mode 100644 index 00000000..74c27daa Binary files /dev/null and b/screenshots/en/frontderailleur-setting.png differ diff --git a/screenshots/es/customization-android-1320x2868.png b/screenshots/es/customization-android-1320x2868.png index da7c6966..2c1e37ca 100644 Binary files a/screenshots/es/customization-android-1320x2868.png and b/screenshots/es/customization-android-1320x2868.png differ diff --git a/screenshots/es/customization-androidTablet-3840x2400.png b/screenshots/es/customization-androidTablet-3840x2400.png index 526c8f7b..0431b812 100644 Binary files a/screenshots/es/customization-androidTablet-3840x2400.png and b/screenshots/es/customization-androidTablet-3840x2400.png differ diff --git a/screenshots/es/customization-desktop-2560x1600.png b/screenshots/es/customization-desktop-2560x1600.png index 9dabdd56..46b748dc 100644 Binary files a/screenshots/es/customization-desktop-2560x1600.png and b/screenshots/es/customization-desktop-2560x1600.png differ diff --git a/screenshots/es/customization-iPad-2752x2064.png b/screenshots/es/customization-iPad-2752x2064.png index 101ee9e0..cb7bebc2 100644 Binary files a/screenshots/es/customization-iPad-2752x2064.png and b/screenshots/es/customization-iPad-2752x2064.png differ diff --git a/screenshots/es/customization-iPhone-1242x2688.png b/screenshots/es/customization-iPhone-1242x2688.png index ab940bd6..a0258113 100644 Binary files a/screenshots/es/customization-iPhone-1242x2688.png and b/screenshots/es/customization-iPhone-1242x2688.png differ diff --git a/screenshots/es/customization-noFrame-1100x2390.png b/screenshots/es/customization-noFrame-1100x2390.png index 077ca4d9..311a196f 100644 Binary files a/screenshots/es/customization-noFrame-1100x2390.png and b/screenshots/es/customization-noFrame-1100x2390.png differ diff --git a/screenshots/fr/customization-android-1320x2868.png b/screenshots/fr/customization-android-1320x2868.png index cf9d5542..8e9e0362 100644 Binary files a/screenshots/fr/customization-android-1320x2868.png and b/screenshots/fr/customization-android-1320x2868.png differ diff --git a/screenshots/fr/customization-androidTablet-3840x2400.png b/screenshots/fr/customization-androidTablet-3840x2400.png index 67298146..1f795322 100644 Binary files a/screenshots/fr/customization-androidTablet-3840x2400.png and b/screenshots/fr/customization-androidTablet-3840x2400.png differ diff --git a/screenshots/fr/customization-desktop-2560x1600.png b/screenshots/fr/customization-desktop-2560x1600.png index 76751146..53e45222 100644 Binary files a/screenshots/fr/customization-desktop-2560x1600.png and b/screenshots/fr/customization-desktop-2560x1600.png differ diff --git a/screenshots/fr/customization-iPad-2752x2064.png b/screenshots/fr/customization-iPad-2752x2064.png index 31eff11d..de1e2b3e 100644 Binary files a/screenshots/fr/customization-iPad-2752x2064.png and b/screenshots/fr/customization-iPad-2752x2064.png differ diff --git a/screenshots/fr/customization-iPhone-1242x2688.png b/screenshots/fr/customization-iPhone-1242x2688.png index 7be1f18b..9831a910 100644 Binary files a/screenshots/fr/customization-iPhone-1242x2688.png and b/screenshots/fr/customization-iPhone-1242x2688.png differ diff --git a/screenshots/fr/customization-noFrame-1100x2390.png b/screenshots/fr/customization-noFrame-1100x2390.png index c756cb0f..eba0cb37 100644 Binary files a/screenshots/fr/customization-noFrame-1100x2390.png and b/screenshots/fr/customization-noFrame-1100x2390.png differ diff --git a/screenshots/it/customization-android-1320x2868.png b/screenshots/it/customization-android-1320x2868.png index 42dc9019..39a47908 100644 Binary files a/screenshots/it/customization-android-1320x2868.png and b/screenshots/it/customization-android-1320x2868.png differ diff --git a/screenshots/it/customization-androidTablet-3840x2400.png b/screenshots/it/customization-androidTablet-3840x2400.png index 1035eb07..fc83f662 100644 Binary files a/screenshots/it/customization-androidTablet-3840x2400.png and b/screenshots/it/customization-androidTablet-3840x2400.png differ diff --git a/screenshots/it/customization-desktop-2560x1600.png b/screenshots/it/customization-desktop-2560x1600.png index f1d1a36f..9ecd8fcd 100644 Binary files a/screenshots/it/customization-desktop-2560x1600.png and b/screenshots/it/customization-desktop-2560x1600.png differ diff --git a/screenshots/it/customization-iPad-2752x2064.png b/screenshots/it/customization-iPad-2752x2064.png index 12b9e307..af2ce1ac 100644 Binary files a/screenshots/it/customization-iPad-2752x2064.png and b/screenshots/it/customization-iPad-2752x2064.png differ diff --git a/screenshots/it/customization-iPhone-1242x2688.png b/screenshots/it/customization-iPhone-1242x2688.png index 615fa0cd..f443fe62 100644 Binary files a/screenshots/it/customization-iPhone-1242x2688.png and b/screenshots/it/customization-iPhone-1242x2688.png differ diff --git a/screenshots/it/customization-noFrame-1100x2390.png b/screenshots/it/customization-noFrame-1100x2390.png index 258acbb1..7a1fac16 100644 Binary files a/screenshots/it/customization-noFrame-1100x2390.png and b/screenshots/it/customization-noFrame-1100x2390.png differ diff --git a/screenshots/pl/customization-android-1320x2868.png b/screenshots/pl/customization-android-1320x2868.png index 4f1d6f67..2525ea4c 100644 Binary files a/screenshots/pl/customization-android-1320x2868.png and b/screenshots/pl/customization-android-1320x2868.png differ diff --git a/screenshots/pl/customization-androidTablet-3840x2400.png b/screenshots/pl/customization-androidTablet-3840x2400.png index bdf4359b..1daa9212 100644 Binary files a/screenshots/pl/customization-androidTablet-3840x2400.png and b/screenshots/pl/customization-androidTablet-3840x2400.png differ diff --git a/screenshots/pl/customization-desktop-2560x1600.png b/screenshots/pl/customization-desktop-2560x1600.png index 420f3a2c..ce27989c 100644 Binary files a/screenshots/pl/customization-desktop-2560x1600.png and b/screenshots/pl/customization-desktop-2560x1600.png differ diff --git a/screenshots/pl/customization-iPad-2752x2064.png b/screenshots/pl/customization-iPad-2752x2064.png index 8591627d..3e56af81 100644 Binary files a/screenshots/pl/customization-iPad-2752x2064.png and b/screenshots/pl/customization-iPad-2752x2064.png differ diff --git a/screenshots/pl/customization-iPhone-1242x2688.png b/screenshots/pl/customization-iPhone-1242x2688.png index b392fb67..9e02c765 100644 Binary files a/screenshots/pl/customization-iPhone-1242x2688.png and b/screenshots/pl/customization-iPhone-1242x2688.png differ diff --git a/screenshots/pl/customization-noFrame-1100x2390.png b/screenshots/pl/customization-noFrame-1100x2390.png index b6104999..6c62525a 100644 Binary files a/screenshots/pl/customization-noFrame-1100x2390.png and b/screenshots/pl/customization-noFrame-1100x2390.png differ diff --git a/test/bluetooth/front_shift_combo_test.dart b/test/bluetooth/front_shift_combo_test.dart new file mode 100644 index 00000000..03b7f288 --- /dev/null +++ b/test/bluetooth/front_shift_combo_test.dart @@ -0,0 +1,132 @@ +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/apps/zwift.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:prop/prop.dart' hide RideButtonMask; + +import '../integration/harness/fake_ble_platform.dart'; +import '../integration/harness/fake_peripherals.dart'; +import '../integration/harness/test_env.dart'; + +/// Test-local StubActions subclass that exposes the combo hooks with full +/// control over [frontShiftComboEnabled], without touching the shared +/// [StubActions] (so other tests are unaffected). +class _ComboStubActions extends StubActions { + bool comboEnabled = true; + final List inGameActionsPerformed = []; + + @override + bool get frontShiftComboEnabled => comboEnabled; + + @override + Future performInGameAction(InGameAction action) async { + inGameActionsPerformed.add(action); + return Success('stub', button: null); + } +} + +Future main() async { + final env = await IntegrationEnv.setUp(); + late _ComboStubActions comboActions; + + core.connection.initialize(); + + setUp(() async { + await env.resetState(); + comboActions = _ComboStubActions(); + comboActions.supportedApp = Zwift(); + core.actionHandler = comboActions; + }); + + tearDown(() async { + await env.resetConnection(); + }); + + Future<(FakePeripheral, ZwiftRide)> connectRide() async { + final ride = buildZwiftRide(); + autoRespondToZwiftHandshake(env.ble, ride, startResponse: ZwiftConstants.RESPONSE_START_PLAY); + env.ble.addPeripheral(ride); + await core.connection.performScanning(); + await IntegrationEnv.waitFor( + () => core.connection.devices.whereType().isNotEmpty, + description: 'Zwift Ride in device list', + ); + final device = core.connection.devices.whereType().first; + await IntegrationEnv.waitFor(() => ride.writes.isNotEmpty, description: 'Zwift Ride handshake'); + return (ride, device); + } + + group('same-frame both-shifters combo', () { + test('combo enabled: single Ride frame with both shift buttons emits frontShift and suppresses rear shifts', + () async { + final (ride, _) = await connectRide(); + + // shiftUpRight → InGameAction.shiftUp; shiftUpLeft → InGameAction.shiftDown + // One frame containing both = same-frame combo → should fire frontShift only. + env.ble.notify( + ride.deviceId, + ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID, + zwiftRideNotification(pressed: [RideButtonMask.SHFT_UP_R_BTN, RideButtonMask.SHFT_UP_L_BTN]), + ); + // Release frame + env.ble.notify( + ride.deviceId, + ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID, + zwiftRideNotification(), + ); + + await IntegrationEnv.waitFor( + () => comboActions.inGameActionsPerformed.isNotEmpty, + description: 'frontShift in-game action', + ); + + expect(comboActions.inGameActionsPerformed, [InGameAction.frontShift]); + + // The rear shifts must be suppressed — performedActions should be empty + // (the combo returns early before performClick is reached). + await Future.delayed(const Duration(milliseconds: 100)); + expect( + comboActions.performedActions.where( + (a) => a.button == ZwiftButtons.shiftUpRight || a.button == ZwiftButtons.shiftUpLeft, + ), + isEmpty, + reason: 'rear shifts must be suppressed when combo fires', + ); + }); + + test('combo disabled: same frame fires both rear shifts normally and no frontShift', () async { + comboActions.comboEnabled = false; + + final (ride, _) = await connectRide(); + + env.ble.notify( + ride.deviceId, + ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID, + zwiftRideNotification(pressed: [RideButtonMask.SHFT_UP_R_BTN, RideButtonMask.SHFT_UP_L_BTN]), + ); + env.ble.notify( + ride.deviceId, + ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID, + zwiftRideNotification(), + ); + + await IntegrationEnv.waitFor( + () => comboActions.performedActions.length >= 2, + description: 'both shift buttons performed', + ); + await Future.delayed(const Duration(milliseconds: 100)); + + expect( + comboActions.performedActions.map((a) => a.button).toSet(), + containsAll([ZwiftButtons.shiftUpRight, ZwiftButtons.shiftUpLeft]), + reason: 'both rear shifts should fire when combo is disabled', + ); + expect(comboActions.inGameActionsPerformed, isEmpty, reason: 'no frontShift when combo is disabled'); + }); + }); +} diff --git a/test/bluetooth/proxy/front_shift_combo_test.dart b/test/bluetooth/proxy/front_shift_combo_test.dart new file mode 100644 index 00000000..0b6aa5e3 --- /dev/null +++ b/test/bluetooth/proxy/front_shift_combo_test.dart @@ -0,0 +1,133 @@ +import 'package:bike_control/bluetooth/devices/proxy/proxy_device.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/models/shifting_config.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' show Locale; +import 'package:flutter_test/flutter_test.dart'; +import 'package:prop/emulators/definitions/fitness_bike_definition.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:universal_ble/universal_ble.dart'; + +Future main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + // performInGameAction → handleTrainerAction reads AppLocalizations.current for + // its result messages, so we initialise the EN bundle once before any test. + await AppLocalizations.load(const Locale('en')); + + // performInGameAction → IAPManager.incrementCommandCount reaches + // Supabase.instance on the free-tier hot path; give it an offline dummy + // instance (no session, so no network request is ever made). + setUpAll(() async { + // Supabase's gotrue async storage reads SharedPreferences during + // initialize(); mock it before so the channel call resolves. + SharedPreferences.setMockInitialValues({}); + await Supabase.initialize( + url: 'http://127.0.0.1:9', + anonKey: 'front-shift-combo-test-anon-key', + debug: false, + authOptions: const FlutterAuthClientOptions( + localStorage: EmptyLocalStorage(), + detectSessionInUri: false, + autoRefreshToken: false, + ), + ); + }); + + late StubActions actions; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + core.settings.prefs = await SharedPreferences.getInstance(); + await core.shiftingConfigs.init(); + core.connection.devices.clear(); + actions = StubActions(); + core.actionHandler = actions; + }); + + group('noteShiftAndCheckCoincidence (window)', () { + test('opposite shift within 120ms → coincidence on the second', () { + var t = DateTime(2026, 1, 1); + actions.nowFn = () => t; + + // shiftDown at t=0 → no coincidence yet. + expect(actions.noteShiftAndCheckCoincidence(InGameAction.shiftDown), isFalse); + + // shiftUp at t=100ms → opposite shift within the window → coincidence. + t = t.add(const Duration(milliseconds: 100)); + expect(actions.noteShiftAndCheckCoincidence(InGameAction.shiftUp), isTrue); + }); + + test('opposite shift 300ms apart → NO coincidence', () { + var t = DateTime(2026, 1, 1); + actions.nowFn = () => t; + + expect(actions.noteShiftAndCheckCoincidence(InGameAction.shiftDown), isFalse); + + t = t.add(const Duration(milliseconds: 300)); + expect(actions.noteShiftAndCheckCoincidence(InGameAction.shiftUp), isFalse); + }); + + test('a hit resets the window so it does not re-trigger', () { + var t = DateTime(2026, 1, 1); + actions.nowFn = () => t; + + actions.noteShiftAndCheckCoincidence(InGameAction.shiftDown); + t = t.add(const Duration(milliseconds: 50)); + expect(actions.noteShiftAndCheckCoincidence(InGameAction.shiftUp), isTrue); + + // A lone shiftUp right after the reset must not coincide with the + // already-consumed shiftDown. + t = t.add(const Duration(milliseconds: 10)); + expect(actions.noteShiftAndCheckCoincidence(InGameAction.shiftUp), isFalse); + }); + }); + + group('frontShiftComboEnabled', () { + test('false when no proxy is connected', () { + expect(actions.frontShiftComboEnabled, isFalse); + }); + + test('reflects the active ShiftingConfig.frontShiftEnabled of a connected proxy', () async { + final device = ProxyDevice(BleDevice(deviceId: 'x', name: 'KICKR')); + device.isConnected = true; + core.connection.devices.add(device); + + // No config yet → defaults → disabled. + expect(actions.frontShiftComboEnabled, isFalse); + + await core.shiftingConfigs.upsert( + ShiftingConfig.defaults(trainerKey: device.trainerKey).copyWith(frontShiftEnabled: true), + ); + expect(actions.frontShiftComboEnabled, isTrue); + }); + }); + + group('performInGameAction(frontShift)', () { + test('toggles the connected proxy front ring and returns Success', () async { + final device = ProxyDevice(BleDevice(deviceId: 'x', name: 'KICKR')); + device.isConnected = true; + core.connection.devices.add(device); + + final def = FitnessBikeDefinition( + connectedDevice: device.scanResult, + connectedDeviceServices: const [], + data: ValueNotifier(''), + ); + device.emulator.debugSetActiveDefinition(def); + def.setChainringTeeth(34, 50); + def.setFrontShiftEnabled(true); + def.setTargetGear(12); // sim mode (non-ERG) + + expect(def.frontRing.value, FrontRing.small); // precondition + + final result = await actions.performInGameAction(InGameAction.frontShift); + + expect(result, isA()); + expect(def.frontRing.value, FrontRing.large); + }); + }); +} diff --git a/test/bluetooth/proxy/handle_trainer_action_consolidated_test.dart b/test/bluetooth/proxy/handle_trainer_action_consolidated_test.dart index 01894f01..a4b7f07c 100644 --- a/test/bluetooth/proxy/handle_trainer_action_consolidated_test.dart +++ b/test/bluetooth/proxy/handle_trainer_action_consolidated_test.dart @@ -77,5 +77,64 @@ Future main() async { expect(result, isA()); expect(def.trainerMode.value, isNot(TrainerMode.ergMode)); }); + + group('frontShift', () { + setUp(() { + // Enable front-shift on the definition, put it in sim mode. + def.setChainringTeeth(34, 50); + def.setFrontShiftEnabled(true); + def.setTargetGear(12); // sim mode (non-ERG) + }); + + test('sim mode: first call shifts to large ring and returns Success', () { + expect(def.frontRing.value, FrontRing.small); // precondition + final result = device.handleTrainerAction( + ZwiftButtons.shiftDownLeft, + InGameAction.frontShift, + ); + expect(result, isA()); + expect(def.frontRing.value, FrontRing.large); + }); + + test('sim mode: second call shifts back to small ring and returns Success', () { + // First toggle → large + device.handleTrainerAction(ZwiftButtons.shiftDownLeft, InGameAction.frontShift); + expect(def.frontRing.value, FrontRing.large); + + // Second toggle → small + final result = device.handleTrainerAction( + ZwiftButtons.shiftDownLeft, + InGameAction.frontShift, + ); + expect(result, isA()); + expect(def.frontRing.value, FrontRing.small); + }); + + test('erg mode: returns Ignored and does not change ring', () { + def.setManualErgPower(150); // switch to ERG + expect(def.trainerMode.value, TrainerMode.ergMode); + final ringBefore = def.frontRing.value; + + final result = device.handleTrainerAction( + ZwiftButtons.shiftDownLeft, + InGameAction.frontShift, + ); + expect(result, isA()); + expect(def.frontRing.value, ringBefore); // unchanged + }); + + test('returns Ignored when front-shift is disabled', () { + def.setFrontShiftEnabled(false); + final ringBefore = def.frontRing.value; + + final result = device.handleTrainerAction( + ZwiftButtons.shiftDownLeft, + InGameAction.frontShift, + ); + expect(result, isA()); + expect((result as Ignored).message, AppLocalizations.current.trainerFrontShiftNotEnabled); + expect(def.frontRing.value, ringBefore); + }); + }); }); } diff --git a/test/gear_readout_test.dart b/test/gear_readout_test.dart new file mode 100644 index 00000000..7e9416e9 --- /dev/null +++ b/test/gear_readout_test.dart @@ -0,0 +1,40 @@ +import 'package:bike_control/utils/gear_readout.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('formatGearReadout', () { + test('shows rear gear / total when front shift is off', () { + expect( + formatGearReadout(currentGear: 14, maxGear: 25, frontShiftEnabled: false, largeRing: false), + '14/25', + ); + }); + + test('largeRing is ignored when front shift is off', () { + expect( + formatGearReadout(currentGear: 14, maxGear: 25, frontShiftEnabled: false, largeRing: true), + '14/25', + ); + }); + + test('small ring shows position 1 × rear gear when front shift is on', () { + expect( + formatGearReadout(currentGear: 14, maxGear: 25, frontShiftEnabled: true, largeRing: false), + '1×14', + ); + }); + + test('large ring shows position 2 × rear gear when front shift is on', () { + expect( + formatGearReadout(currentGear: 14, maxGear: 25, frontShiftEnabled: true, largeRing: true), + '2×14', + ); + }); + + test('drops the total when front shift is on (position notation only)', () { + final out = formatGearReadout(currentGear: 7, maxGear: 30, frontShiftEnabled: true, largeRing: true); + expect(out, '2×7'); + expect(out.contains('/'), isFalse); + }); + }); +} diff --git a/test/integration/screen_recording_action_test.dart b/test/integration/screen_recording_action_test.dart new file mode 100644 index 00000000..eda05216 --- /dev/null +++ b/test/integration/screen_recording_action_test.dart @@ -0,0 +1,98 @@ +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/services/screen_recording/screen_recording_service.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/utils/keymap/apps/zwift.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'harness/test_env.dart'; + +/// Minimal concrete BaseActions so the REAL performAction handler runs +/// (BaseActions is abstract only on cleanup()). +class _TestActions extends BaseActions { + _TestActions() : super(supportedModes: const []); + @override + void cleanup() {} +} + +class _FakeBackend implements ScreenRecorderBackend { + bool available = true; + int starts = 0; + int stops = 0; + @override + Future isAvailable() async => available; + @override + Future ensurePermission() async => true; + @override + Future start() async { + starts++; + return true; + } + + @override + Future stop() async { + stops++; + return '/tmp/x.mp4'; + } +} + +Future main() async { + final env = await IntegrationEnv.setUp(); + late _TestActions actions; + late _FakeBackend backend; + + const button = ControllerButton('screenRecTestBtn'); + + setUp(() async { + await env.resetState(); + // Screen recording is a Pro action; enable Pro so proGuard lets the handler run. + IAPManager.instance.setProForTesting(enabled: true); + backend = _FakeBackend(); + core.screenRecording = ScreenRecordingService(backend: backend); + + final app = Zwift(); + app.keymap.keyPairs.add( + KeyPair(buttons: const [button], physicalKey: null, logicalKey: null, inGameAction: InGameAction.screenRecording), + ); + actions = _TestActions()..supportedApp = app; + core.actionHandler = actions; + }); + + test('key-down toggles recording on and returns started', () async { + final result = await actions.performAction(button, isKeyDown: true, isKeyUp: false); + expect(result, isA()); + expect(result.message, AppLocalizations.current.screenRecordingStarted); + expect(backend.starts, 1); + }); + + test('key-up is ignored (no double toggle)', () async { + final result = await actions.performAction(button, isKeyDown: false, isKeyUp: true); + expect(result, isA()); + expect(backend.starts, 0); + }); + + test('second key-down stops and saves', () async { + await actions.performAction(button, isKeyDown: true, isKeyUp: false); + final result = await actions.performAction(button, isKeyDown: true, isKeyUp: false); + expect(result, isA()); + expect(backend.stops, 1); + }); + + test('unsupported device returns Ignored not-supported', () async { + backend.available = false; + final result = await actions.performAction(button, isKeyDown: true, isKeyUp: false); + expect(result, isA()); + expect(result.message, AppLocalizations.current.screenRecordingNotSupported); + }); + + test('non-Pro user is blocked by the Pro gate (recording never starts)', () async { + IAPManager.instance.setProForTesting(enabled: false); + final result = await actions.performAction(button, isKeyDown: true, isKeyUp: false); + expect(result, isA()); + expect((result as Error).type, ErrorType.proRequired); + expect(backend.starts, 0); + }); +} diff --git a/test/models/shifting_config_front_shift_test.dart b/test/models/shifting_config_front_shift_test.dart new file mode 100644 index 00000000..8ed863c2 --- /dev/null +++ b/test/models/shifting_config_front_shift_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:bike_control/models/shifting_config.dart'; + +void main() { + test('defaults: front shift off, 34/50 chainrings', () { + final c = ShiftingConfig.defaults(trainerKey: 'k'); + expect(c.frontShiftEnabled, isFalse); + expect(c.smallChainringTeeth, 34); + expect(c.largeChainringTeeth, 50); + }); + + test('round-trips through JSON', () { + final c = ShiftingConfig.defaults(trainerKey: 'k').copyWith( + frontShiftEnabled: true, + smallChainringTeeth: 36, + largeChainringTeeth: 52, + ); + final back = ShiftingConfig.fromJson(c.toJson()); + expect(back.frontShiftEnabled, isTrue); + expect(back.smallChainringTeeth, 36); + expect(back.largeChainringTeeth, 52); + expect(back, c); + }); + + test('fromJson falls back to defaults when keys absent', () { + final back = ShiftingConfig.fromJson({'trainerKey': 'k'}); + expect(back.frontShiftEnabled, isFalse); + expect(back.smallChainringTeeth, 34); + expect(back.largeChainringTeeth, 50); + }); +} diff --git a/test/openbikecontrol_app_info_reassembler_test.dart b/test/openbikecontrol_app_info_reassembler_test.dart new file mode 100644 index 00000000..3e365d33 --- /dev/null +++ b/test/openbikecontrol_app_info_reassembler_test.dart @@ -0,0 +1,124 @@ +import 'dart:typed_data'; + +import 'package:bike_control/bluetooth/devices/openbikecontrol/app_info_reassembler.dart'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // A complete, valid app-info message. No buttons keeps the fixture + // independent of the button-id table while still exercising appId/version. + final complete = OpenBikeProtocolParser.encodeAppInfo( + appId: 'TrainingPeaks', + appVersion: '6.0', + supportedButtons: [], + ); + + /// Split [data] into [count] roughly-equal fragments, mimicking an app that + /// dribbles the app-info write across several BLE packets. Each strict prefix + /// fails to parse, so feeding the fragments in order drives the reassembler. + List fragment(Uint8List data, int count) { + final size = (data.length / count).ceil(); + return [ + for (var i = 0; i < data.length; i += size) + Uint8List.sublistView(data, i, (i + size) > data.length ? data.length : i + size), + ]; + } + + test('parses a complete single write immediately', () { + final r = AppInfoReassembler(); + + final info = r.offer(complete); + + expect(info, isNotNull); + expect(info!.appId, 'TrainingPeaks'); + expect(info.appVersion, '6.0'); + expect(r.pendingFragments, 0, reason: 'buffer is cleared on success'); + }); + + test('reassembles a message split across two writes', () { + final parts = fragment(complete, 2); + expect(parts.length, 2); + final r = AppInfoReassembler(); + + expect(r.offer(parts[0]), isNull, reason: 'first fragment is incomplete'); + expect(r.pendingFragments, 1); + + final info = r.offer(parts[1]); + expect(info, isNotNull); + expect(info!.appId, 'TrainingPeaks'); + expect(r.pendingFragments, 0); + }); + + test('reassembles a message split across THREE writes (single-buffer regression)', () { + // The previous single prior-fragment buffer kept only the FIRST failed + // fragment and dropped the middle one, so a three-packet app-info + // (TrainingPeaks on macOS) never reconnected. Every fragment must be kept. + final parts = fragment(complete, 3); + expect(parts.length, 3); + final r = AppInfoReassembler(); + + expect(r.offer(parts[0]), isNull); + expect(r.offer(parts[1]), isNull); + expect(r.pendingFragments, 2, reason: 'every incomplete fragment is retained'); + + final info = r.offer(parts[2]); + expect(info, isNotNull, reason: 'all three fragments reassemble'); + expect(info!.appId, 'TrainingPeaks'); + expect(r.pendingFragments, 0); + }); + + test('resets after a success so the next message is not polluted by old fragments', () { + final r = AppInfoReassembler(); + + final first = fragment(complete, 2); + expect(r.offer(first[0]), isNull); + expect(r.offer(first[1]), isNotNull); + + // A different message reusing the same reassembler must parse cleanly. + final second = OpenBikeProtocolParser.encodeAppInfo( + appId: 'Rouvy', + appVersion: '1.2', + supportedButtons: [], + ); + final secondParts = fragment(second, 2); + expect(r.offer(secondParts[0]), isNull); + + final info = r.offer(secondParts[1]); + expect(info, isNotNull); + expect(info!.appId, 'Rouvy'); + }); + + test('exposes the parse error while a message is incomplete', () { + final r = AppInfoReassembler(); + + expect(r.offer(fragment(complete, 2)[0]), isNull); + expect(r.lastError, isA()); + }); + + test('reset() drops a half-received message so the next one parses clean', () { + final r = AppInfoReassembler(); + + // A central sends a partial app-info, then disconnects. + expect(r.offer(fragment(complete, 2)[0]), isNull); + expect(r.pendingFragments, 1); + r.reset(); // emulator calls this on disconnect + expect(r.pendingFragments, 0); + + // A freshly-connected central's complete write must parse — without reset + // the stale prefix would poison it forever. + final info = r.offer(complete); + expect(info, isNotNull); + expect(info!.appId, 'TrainingPeaks'); + }); + + test('bounds the buffer under a flood of unparseable writes', () { + final r = AppInfoReassembler(); + final junk = Uint8List.fromList(List.filled(64, 0xFF)); // never a valid app-info + + for (var i = 0; i < 100; i++) { + expect(r.offer(junk), isNull); + } + // 100 × 64B = 6400B if unbounded; the 512B cap keeps it small (no leak). + expect(r.pendingFragments, lessThanOrEqualTo(8)); + }); +} diff --git a/test/peripheral_advertising_recovery_test.dart b/test/peripheral_advertising_recovery_test.dart new file mode 100644 index 00000000..1da7bda9 --- /dev/null +++ b/test/peripheral_advertising_recovery_test.dart @@ -0,0 +1,118 @@ +import 'package:bike_control/bluetooth/peripheral_advertising_recovery.dart'; +import 'package:bike_control/bluetooth/peripheral_server.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A [PeripheralServer] whose advertising calls are recorded instead of hitting +/// the real CoreBluetooth/universal_ble platform. The recovery mixin only ever +/// calls [stopAdvertising], so that is all we override (never calling `super`, +/// which would touch the global peripheral platform). +class _FakePeripheralServer extends PeripheralServer { + _FakePeripheralServer(this._log); + final List _log; + int stopAdvertisingCalls = 0; + + @override + Future stopAdvertising() async { + stopAdvertisingCalls++; + _log.add('stop'); + } +} + +/// Minimal host mixing in the recovery behaviour. Records each service +/// (re)start and can optionally re-enter recovery mid-restart to exercise the +/// re-entrancy guard. +class _RecoveryHost with PeripheralAdvertisingRecovery { + _RecoveryHost(this._server, this._log); + final PeripheralServer _server; + final List _log; + + int startServiceCalls = 0; + + /// If set, invoked while a restart is in flight (i.e. while recovery still + /// holds its guard) — used to prove a nested recovery is rejected. + Future Function()? duringStart; + + @override + PeripheralServer get advertisingServer => _server; + + @override + Future startServiceAdvertising() async { + startServiceCalls++; + _log.add('start'); + final hook = duringStart; + if (hook != null) await hook(); + } +} + +void main() { + late List log; + late _FakePeripheralServer server; + late _RecoveryHost host; + + setUp(() { + log = []; + server = _FakePeripheralServer(log); + host = _RecoveryHost(server, log); + }); + + group('recoverIfAlreadyAdvertising', () { + test('ignores a null error', () async { + expect(await host.recoverIfAlreadyAdvertising(null), isFalse); + expect(server.stopAdvertisingCalls, 0); + expect(host.startServiceCalls, 0); + }); + + test('ignores an unrelated error (no "already")', () async { + // e.g. the Zwift "Data too large" advertise failure must NOT be treated + // as an already-advertising collision. + expect(await host.recoverIfAlreadyAdvertising('Data too large'), isFalse); + expect(log, isEmpty); + }); + + test('recovers an "already started" error: stop then restart, exactly once', () async { + final handled = await host.recoverIfAlreadyAdvertising('Advertising has already started'); + + expect(handled, isTrue, reason: 'caller should then NOT also warn'); + expect(server.stopAdvertisingCalls, 1); + expect(host.startServiceCalls, 1); + expect(log, ['stop', 'start'], reason: 'must stop the stale advertisement before restarting ours'); + }); + + test('matches "already" case-insensitively', () async { + expect(await host.recoverIfAlreadyAdvertising('ALREADY ADVERTISING'), isTrue); + expect(log, ['stop', 'start']); + }); + + test('is re-entrancy guarded: a nested recovery while restarting is rejected', () async { + bool? nested; + host.duringStart = () async { + // Runs while the first recovery still holds the guard — a persistent + // error re-firing here must not trigger a second stop/restart loop. + nested = await host.recoverIfAlreadyAdvertising('already started'); + }; + + final outer = await host.recoverIfAlreadyAdvertising('already started'); + + expect(outer, isTrue); + expect(nested, isFalse, reason: 'a recovery already in flight must not re-enter'); + expect(server.stopAdvertisingCalls, 1, reason: 'guard prevents a second stop'); + expect(host.startServiceCalls, 1, reason: 'guard prevents a second restart'); + }); + + test('releases the guard after recovery, so a later error recovers again', () async { + await host.recoverIfAlreadyAdvertising('already started'); + final second = await host.recoverIfAlreadyAdvertising('already started'); + + expect(second, isTrue); + expect(server.stopAdvertisingCalls, 2); + expect(host.startServiceCalls, 2); + }); + }); + + group('restartAdvertising', () { + test('unconditionally stops the stale advertisement then restarts ours', () async { + await host.restartAdvertising(); + expect(log, ['stop', 'start']); + }); + }); +} diff --git a/test/screenshot_test.dart b/test/screenshot_test.dart index cb5b788c..8fded0c4 100644 --- a/test/screenshot_test.dart +++ b/test/screenshot_test.dart @@ -1,21 +1,51 @@ +import 'package:bike_control/bluetooth/devices/base_device.dart'; +import 'package:bike_control/bluetooth/devices/bluetooth_device.dart'; +import 'package:bike_control/bluetooth/devices/cycplus/cycplus_bc2.dart'; +import 'package:bike_control/bluetooth/devices/elite/elite_sterzo.dart'; +import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart'; import 'package:bike_control/bluetooth/devices/proxy/proxy_device.dart'; +import 'package:bike_control/bluetooth/devices/thinkrider/thinkrider_vs200.dart'; import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_click.dart'; import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_play.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; import 'package:bike_control/gen/l10n.dart'; import 'package:bike_control/main.dart'; import 'package:bike_control/pages/button_simulator.dart'; +import 'package:bike_control/pages/configuration.dart'; import 'package:bike_control/pages/controller_settings.dart'; +import 'package:bike_control/pages/navigation.dart'; +import 'package:bike_control/pages/overview.dart'; import 'package:bike_control/pages/proxy_device_details.dart'; +import 'package:bike_control/pages/proxy_device_details/front_shift_card.dart'; import 'package:bike_control/pages/proxy_device_details/gear_ratios_editor_page.dart'; import 'package:bike_control/pages/trainer_connection_settings.dart'; import 'package:bike_control/utils/core.dart' show core; import 'package:bike_control/utils/iap/iap_manager.dart'; import 'package:bike_control/utils/keymap/apps/bike_control.dart'; import 'package:bike_control/utils/keymap/apps/my_whoosh.dart'; +import 'package:bike_control/utils/keymap/apps/rouvy.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/keymap/apps/training_peaks.dart'; import 'package:bike_control/utils/keymap/apps/zwift.dart'; import 'package:bike_control/utils/keymap/buttons.dart'; import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/services/overlay/overlay_state.dart'; +import 'package:bike_control/services/overview_screenshot.dart'; import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:bike_control/widgets/apps/di2_ble_tile.dart'; +import 'package:bike_control/widgets/apps/local_tile.dart'; +import 'package:bike_control/widgets/apps/mywhoosh_link_tile.dart'; +import 'package:bike_control/widgets/apps/openbikecontrol_ble_tile.dart'; +import 'package:bike_control/widgets/apps/openbikecontrol_mdns_tile.dart'; +import 'package:bike_control/widgets/apps/zwift_mdns_tile.dart'; +import 'package:bike_control/widgets/apps/zwift_tile.dart'; +import 'package:bike_control/widgets/controller/controller_canvas.dart'; +import 'package:bike_control/widgets/overlay/trainer_overlay_view.dart'; +import 'package:bike_control/widgets/ui/animated_button_widget.dart'; import 'package:flutter/material.dart' as ma; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -129,6 +159,54 @@ Future main() async { core.connection.addDevices([device, proxy]); + // Connected instances of every controller we shoot a full connection-card + // golden for. Each is given a real advertised name (so the card header shows + // the actual product name) and the connected meta (battery/rssi/firmware) the + // overview list would display. `device` above is the Zwift Click v2 instance. + BleDevice scan(String name) => BleDevice(name: name, deviceId: '00:11:22:33:44:55'); + // BLE controllers carry battery / rssi / firmware meta (defined on + // BluetoothDevice); set it so the card header shows the connected status line. + T connectedBle(T d) { + d + ..firmwareVersion = '1.2.0' + ..isConnected = true + ..rssi = -51 + ..batteryLevel = 81; + return d; + } + + final zwiftClick = connectedBle(ZwiftClick(scan('Zwift Click'))); + final zwiftRide = connectedBle(ZwiftRide(scan('Zwift Ride'))); + final zwiftPlay = connectedBle( + ZwiftPlay(scan('Zwift Play'), deviceType: ZwiftDeviceType.playLeft), + ); + final cycplusBc2 = connectedBle(CycplusBc2(scan('CYCPLUS BC2'))); + final thinkriderVs200 = connectedBle(ThinkRiderVs200(scan('THINK VS200'))); + final eliteSterzo = connectedBle(EliteSterzo(scan('STERZO'))); + // GyroscopeSteering extends BaseDevice directly (no BLE), so it has no + // battery/rssi/firmware — just mark it connected. + final gyroSteering = GyroscopeSteering()..isConnected = true; + + // All controllers must live in core.connection.devices so the MyWhoosh + // action handler (initialized below) seeds each button's default in-game + // action into the keymap — that's what renders the action badges. + core.connection.addDevices([ + zwiftClick, + zwiftRide, + zwiftPlay, + cycplusBc2, + thinkriderVs200, + eliteSterzo, + gyroSteering, + ]); + + // core.actionHandler is `late` (assigned in the app's main(), which the test + // harness never runs). Provide a StubActions and init it with MyWhoosh so + // core.actionHandler.supportedApp?.keymap is the populated MyWhoosh keymap the + // connected-Controllers footer passes to AnimatedButtonWidget. + core.actionHandler = StubActions(); + core.actionHandler.init(MyWhoosh()); + final firstButton = ZwiftButtons.b.copyWith(sourceDeviceId: device.uniqueId); final keyEntry = keymap.keymap.getOrCreateKeyPair(firstButton, trigger: ButtonTrigger.longPress); keyEntry.inGameAction = InGameAction.steerRight; @@ -266,6 +344,101 @@ Future main() async { } } + // Blog screenshot: a single clean frameless (noFrame) English shot written to + // ../screenshots/en/-noFrame-1100x2390.png — used for the website blog, + // not the localized App Store matrix that [shoot] produces. + Future shootOne( + WidgetTester tester, + String scene, + Widget Function() home, { + Finder Function()? capture, + Future Function(WidgetTester tester)? afterPump, + TargetPlatform platform = TargetPlatform.android, + }) async { + final nf = sizes.firstWhere((s) => s.type == DeviceType.noFrame); + await AppLocalizations.load(const Locale('en')); + screenshotLocale = const Locale('en'); + await tester.pumpWidget( + ScreenshotApp( + locale: const Locale('en'), + device: ScreenshotDevice( + // Default Android, never the entry's Windows platform: shadcn's theme + // queries the Windows accent colour via advapi32.dll, which can't load + // on a macOS test host. + platform: platform, + resolution: nf.size, + pixelRatio: 3, + goldenSubFolder: 'iphoneScreenshots/', + frameBuilder: + ({ + required ScreenshotDevice device, + required ScreenshotFrameColors? frameColors, + required Widget child, + }) => CustomFrame(platform: DeviceType.noFrame, title: '', device: device, child: child), + ), + home: home(), + ), + ); + await tester.pump(); + if (afterPump != null) await afterPump(tester); + await tester.loadAssets(); + await tester.pump(); + await expectLater( + capture?.call() ?? find.byType(ma.Scaffold), + matchesGoldenFile('../screenshots/en/$scene.png'), + ); + } + + // Localized widget snapshot: like [shootOne], but renders the (frameless, + // noFrame) widget once per locale and writes ../screenshots//.png. + // Used for the website setup guides, which show the matching-language widget + // screenshots. The website uses the en/de/es/fr/it intersection of the app's + // and the site's supported locales; Czech falls back to en on the website. + Future shootLocalized( + WidgetTester tester, + String scene, + Widget Function() home, { + Finder Function()? capture, + Future Function(WidgetTester tester)? afterPump, + TargetPlatform platform = TargetPlatform.android, + }) async { + const widgetLocales = ['en', 'de', 'es', 'fr', 'it']; + final nf = sizes.firstWhere((s) => s.type == DeviceType.noFrame); + for (final loc in widgetLocales) { + await AppLocalizations.load(Locale(loc)); + screenshotLocale = Locale(loc); + await tester.pumpWidget( + ScreenshotApp( + locale: Locale(loc), + device: ScreenshotDevice( + // Default Android, never the entry's Windows platform: shadcn's theme + // queries the Windows accent colour via advapi32.dll, which can't load + // on a macOS test host. + platform: platform, + resolution: nf.size, + pixelRatio: 3, + goldenSubFolder: 'iphoneScreenshots/', + frameBuilder: + ({ + required ScreenshotDevice device, + required ScreenshotFrameColors? frameColors, + required Widget child, + }) => CustomFrame(platform: DeviceType.noFrame, title: '', device: device, child: child), + ), + home: home(), + ), + ); + await tester.pump(); + if (afterPump != null) await afterPump(tester); + await tester.loadAssets(); + await tester.pump(); + await expectLater( + capture?.call() ?? find.byType(ma.Scaffold), + matchesGoldenFile('../screenshots/$loc/$scene.png'), + ); + } + } + testGoldens('Device', (WidgetTester tester) async { await shoot(tester, 'device', () => BikeControlApp()); }); @@ -333,4 +506,589 @@ Future main() async { ), ); }); + + // --- 6.2 Virtual front derailleur (blog widget snapshots) --- + // Each widget is rendered standalone inside a keyed RepaintBoundary so the + // golden captures ONLY that widget (no page chrome). + + // The second-window / desktop gear overlay (TrainerOverlayView), as shown on + // Windows & macOS. Large ring → the primary readout uses the 2×N position + // notation (here 2×14). Mirrors desktop_overlay_window: bare overlay on white. + testGoldens('Front Derailleur Gear', (WidgetTester tester) async { + const k = ValueKey('shot'); + final state = ValueNotifier( + const TrainerOverlayState( + gear: 14, + maxGear: 24, + gearRatio: 3.53, + mode: TrainerMode.simMode, + powerW: 250, + cadenceRpm: 90, + ergTargetW: null, + fields: {OverlayField.power, OverlayField.cadence}, + frontShiftEnabled: true, + frontRingLarge: true, + ), + ); + await shootOne( + tester, + 'frontderailleur-gear', + () => BikeControlApp( + customChild: Center( + child: RepaintBoundary( + key: k, + child: ColoredBox( + color: const Color(0xFFFFFFFF), + child: SizedBox( + width: 240, + child: TrainerOverlayView(state: state, onModeToggle: null), + ), + ), + ), + ), + ), + capture: () => find.byKey(k), + platform: TargetPlatform.macOS, + ); + }); + + // The front-derailleur setting card, enabled so the chainring steppers show. + testGoldens('Front Derailleur Setting', (WidgetTester tester) async { + await core.shiftingConfigs.upsert( + core.shiftingConfigs.activeFor(proxy.trainerKey).copyWith( + frontShiftEnabled: true, + smallChainringTeeth: 34, + largeChainringTeeth: 50, + ), + ); + const k = ValueKey('shot'); + await shootOne( + tester, + 'frontderailleur-setting', + () => BikeControlApp( + customChild: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: RepaintBoundary(key: k, child: FrontShiftCard(device: proxy, definition: fbd)), + ), + ), + ), + capture: () => find.byKey(k), + ); + }); + + // --- MyWhoosh setup-guide widget snapshots (website setup guide) --- + // Tight single-widget captures of the two BikeControl controls the MyWhoosh + // setup guide walks through: the trainer-app picker (showing MyWhoosh) and the + // "Connect directly over Network" connection method. Rendered standalone inside + // a keyed RepaintBoundary so the golden captures ONLY the widget. + + // The trainer-app picker with MyWhoosh selected. screenshotMode stays on (it + // suppresses the real BLE bootstrap) and TrainerAppSelect.showRealName forces + // the closed Select to show the real "MyWhoosh" name + logo instead of the + // generic "Trainer app" placeholder the marketing screenshots use. + testGoldens('mywhoosh-trainer-select', (WidgetTester tester) async { + core.settings.setTrainerApp(MyWhoosh()); + core.settings.setKeyMap(MyWhoosh()); + const k = ValueKey('shot'); + await shootLocalized( + tester, + 'mywhoosh-trainer-select', + () => BikeControlApp( + customChild: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: RepaintBoundary( + key: k, + child: TrainerAppSelect(onUpdate: () {}, showRealName: true), + ), + ), + ), + ), + capture: () => find.byKey(k), + ); + }); + + // The Network connection method (OpenBikeControl over mDNS), as shown for + // MyWhoosh, in its disabled/off state. The tile passes no requirements, so no + // real BLE is touched even though screenshotMode hides the "Recommended" badge. + testGoldens('mywhoosh-network-connection', (WidgetTester tester) async { + core.settings.setTrainerApp(MyWhoosh()); + core.settings.setKeyMap(MyWhoosh()); + core.settings.setObpMdnsEnabled(false); + // Force the off / not-yet-connected state so the captured card is identical + // regardless of any emulator state a prior scene left behind (the shown + // description and height depend on isStarted/connectedApp). + core.obpMdnsEmulator.isStarted.value = false; + core.obpMdnsEmulator.connectedApp.value = null; + const k = ValueKey('shot'); + await shootLocalized( + tester, + 'mywhoosh-network-connection', + () => BikeControlApp( + customChild: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: RepaintBoundary( + key: k, + child: OpenBikeControlMdnsTile(small: false), + ), + ), + ), + ), + capture: () => find.byKey(k), + ); + }); + + // --- Full connection card (connected-controller list entry) --- + // The complete card the overview's Controllers list renders for a connected + // controller: `device.showInformation` draws the header Row (StatusIcon + + // name + Beta pill + status meta) and, below it, the footer — the + // `ControllerCanvas` contour with its buttons. Two deviations from the live + // app, both deliberate so the image is chrome-free: + // * `showSettingsIcon: false` hides the small header gear (settings live on + // a separate page, not in this card). + // * `showAdditionalInfo: false` drops the device's own state chrome (e.g. + // the Zwift Click unlock warning) so the card is state-agnostic. + // The footer's buttons DO carry the MyWhoosh keymap (via + // `core.actionHandler.supportedApp?.keymap`), so each button that maps to an + // in-game action renders its supported-action badge — exactly like the + // connected-Controllers list. Buttons with no action (e.g. pure steering on + // the Sterzo / phone) render as bare buttons. + // + // `ZwiftClickV2.toString()` anonymizes its name to "Controller" while the + // marketing `screenshotMode` is on; we flip it off *synchronously* around just + // the `showInformation` build so the header shows the real product name — and + // restore it immediately, before any async (the app's connection-init scan + // reads `screenshotMode` on a later microtask, by which point it is true + // again, so no BLE scan fires). Only ClickV2 needs this, but doing it + // uniformly is harmless for the other devices. + // + // Rendered inside `BikeControlApp` so the card has the real theme and an + // `i18n` context, standalone in a keyed RepaintBoundary (captured tight) at a + // fixed list-card width. + Future shootCard(WidgetTester tester, String scene, BaseDevice cardDevice) async { + const k = ValueKey('shot'); + final savedScreenshotMode = screenshotMode; + try { + await shootOne( + tester, + scene, + () => BikeControlApp( + customChild: SingleChildScrollView( + child: Center( + child: SizedBox( + width: 340, + child: Padding( + padding: const EdgeInsets.all(16), + child: Builder( + builder: (context) { + // Footer mirrors overview.dart's footerBuilder: the + // active app's keymap drives the per-button action badges. + final keymap = core.actionHandler.supportedApp?.keymap; + final size = 56 / Theme.of(context).scaling; + Widget btnFor(ControllerButton btn) => AnimatedButtonWidget( + key: ValueKey(btn.name), + button: btn, + pressGeneration: 0, + keymap: keymap, + device: cardDevice, + size: size, + onUpdate: () {}, + ); + final footer = ControllerCanvas( + layout: cardDevice.controllerLayout!, + availableButtons: cardDevice.availableButtons, + buttonBuilder: btnFor, + buttonSize: size, + ); + // Flip screenshotMode off only for this synchronous build + // so the header shows the real product name, then restore + // it before control returns to the framework. + final saved = screenshotMode; + screenshotMode = false; + final card = cardDevice.showInformation( + context, + showFull: false, + showSettingsIcon: false, + showAdditionalInfo: false, + footer: footer, + ); + screenshotMode = saved; + return RepaintBoundary(key: k, child: card); + }, + ), + ), + ), + ), + ), + ), + capture: () => find.byKey(k), + ); + } finally { + screenshotMode = savedScreenshotMode; + } + } + + testGoldens('controller-zwift-click', (WidgetTester tester) async { + await shootCard(tester, 'controller-zwift-click', zwiftClick); + }); + + testGoldens('controller-zwift-click-v2', (WidgetTester tester) async { + await shootCard(tester, 'controller-zwift-click-v2', device); + }); + + testGoldens('controller-zwift-ride', (WidgetTester tester) async { + await shootCard(tester, 'controller-zwift-ride', zwiftRide); + }); + + testGoldens('controller-zwift-play', (WidgetTester tester) async { + await shootCard(tester, 'controller-zwift-play', zwiftPlay); + }); + + testGoldens('controller-cycplus-bc2', (WidgetTester tester) async { + await shootCard(tester, 'controller-cycplus-bc2', cycplusBc2); + }); + + testGoldens('controller-thinkrider-vs200', (WidgetTester tester) async { + await shootCard(tester, 'controller-thinkrider-vs200', thinkriderVs200); + }); + + testGoldens('controller-elite-sterzo', (WidgetTester tester) async { + await shootCard(tester, 'controller-elite-sterzo', eliteSterzo); + }); + + testGoldens('controller-phone-steering', (WidgetTester tester) async { + await shootCard(tester, 'controller-phone-steering', gyroSteering); + }); + + // --- Rouvy & TrainingPeaks setup-guide widget snapshots (website setup guide) --- + // Tight single-widget captures mirroring the MyWhoosh setup-guide scenes above: + // the trainer-app picker (showing the active app's real name + logo) and the + // connection methods the guide walks through. The connection-methods scene + // reproduces `trainer.dart`'s `recommendedTiles` conditional expression VERBATIM + // (see [recommendedConnectionTiles]) so the snapshot shows exactly the tiles the + // live app renders for whichever app is active — driven by `core.logic.show*`. + // These flags are derived from the selected app's declared `connections`: + // * Rouvy supports (rouvyMdns, zwiftBle) → ZwiftMdnsTile + ZwiftTile. + // * TrainingPeaks supports (obpBle, obpDirCon) → OpenBikeControlMdnsTile + + // OpenBikeControlBluetoothTile. + // The BLE-backed flags (showZwiftBleEmulator / showObpBluetoothEmulator) ALSO + // require `getLastTarget() != Target.thisDevice`, so [setActiveApp] persists + // `Target.otherDevice` (the "run the app on another device" setup-guide path). + + // Helper: set the active app exactly as selecting it in the app would, so the + // `core.logic.show*` flags become what the live app uses, then force every + // emulator into its off / not-yet-connected state so the captured cards are + // identical regardless of any emulator state a prior scene left behind (the + // shown description and height depend on isStarted/isConnected/connectedApp). + void setActiveApp(SupportedApp app) { + core.settings.setTrainerApp(app); + core.settings.setKeyMap(app); + // Selecting "another device" as the target is what enables the Bluetooth + // connection methods (showZwiftBleEmulator / showObpBluetoothEmulator both + // gate on getLastTarget() != Target.thisDevice). Without a non-null, + // non-thisDevice target the BLE tiles — and the whole tile block — are hidden. + core.settings.setLastTarget(Target.otherDevice); + // OBC (TrainingPeaks) tiles. + core.settings.setObpMdnsEnabled(false); + core.settings.setObpBleEnabled(false); + core.obpMdnsEmulator.isStarted.value = false; + core.obpMdnsEmulator.connectedApp.value = null; + core.obpMdnsEmulator.isConnected.value = false; + core.obpBluetoothEmulator.isStarted.value = false; + core.obpBluetoothEmulator.connectedApp.value = null; + core.obpBluetoothEmulator.isConnected.value = false; + // Zwift-protocol (Rouvy) tiles. + core.settings.setZwiftMdnsEmulatorEnabled(false); + core.settings.setZwiftBleEmulatorEnabled(false); + core.rouvyMdnsEmulator.isStarted.value = false; + core.rouvyMdnsEmulator.isConnected.value = false; + core.zwiftMdnsEmulator.isStarted.value = false; + core.zwiftMdnsEmulator.isConnected.value = false; + core.zwiftEmulator.isStarted.value = false; + core.zwiftEmulator.isConnected.value = false; + } + + // The connection-method tiles the live app renders for the active app. This is + // `trainer.dart`'s `recommendedTiles` expression reproduced VERBATIM (same + // `if (core.logic.showX) XTile(small: false)` set, same order) so the snapshot + // matches the live app for whatever app `setActiveApp` selected. The onUpdate + // callbacks mirror trainer.dart's (signal a log notification); they never fire + // in the static snapshot. + List recommendedConnectionTiles() { + // Verbatim from trainer.dart: the leading `false &&` is intentional (the + // "show Local as an Other method" path is currently disabled there). + // ignore: dead_code + final showLocalAsOther = false && core.logic.showLocalControl && !core.settings.getLocalEnabled(); + final showWhooshLinkAsOther = + (core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && core.logic.showMyWhooshLink; + return [ + if (core.logic.showObpMdnsEmulator) OpenBikeControlMdnsTile(small: false), + if (core.logic.showObpBluetoothEmulator) OpenBikeControlBluetoothTile(small: false), + if (core.logic.showZwiftMsdnEmulator) + ZwiftMdnsTile( + small: false, + onUpdate: () { + core.connection.signalNotification( + LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'), + ); + }, + ), + if (core.logic.showZwiftBleEmulator) + ZwiftTile( + small: false, + onUpdate: () { + core.connection.signalNotification( + LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'), + ); + }, + ), + if (core.logic.showDi2Ble) Di2BleTile(small: false), + if (core.logic.showLocalControl && !showLocalAsOther) LocalTile(small: false), + if (core.logic.showMyWhooshLink && !showWhooshLinkAsOther) MyWhooshLinkTile(small: false), + ]; + } + + // Render the recommended connection-method tiles for the active app the same + // way the trainer page lays them out (each tile in an IntrinsicHeight, stacked + // in a Column with 12px gaps), inside a keyed RepaintBoundary so the golden + // captures only the tile stack at a fixed setup-guide width. + Future shootConnectionMethods(WidgetTester tester, String scene) async { + const k = ValueKey('shot'); + await shootLocalized( + tester, + scene, + () => BikeControlApp( + customChild: SingleChildScrollView( + child: Center( + child: SizedBox( + width: 340, + child: Padding( + padding: const EdgeInsets.all(24), + child: RepaintBoundary( + key: k, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final tile in recommendedConnectionTiles()) + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: IntrinsicHeight(child: tile), + ), + ], + ), + ), + ), + ), + ), + ), + ), + capture: () => find.byKey(k), + ); + } + + // The trainer-app picker with Rouvy selected (showRealName forces the real + // "Rouvy" name + logo instead of the generic "Trainer app" placeholder). + testGoldens('rouvy-trainer-select', (WidgetTester tester) async { + setActiveApp(Rouvy()); + const k = ValueKey('shot'); + await shootLocalized( + tester, + 'rouvy-trainer-select', + () => BikeControlApp( + customChild: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: RepaintBoundary( + key: k, + child: TrainerAppSelect(onUpdate: () {}, showRealName: true), + ), + ), + ), + ), + capture: () => find.byKey(k), + ); + }); + + // The connection methods the live app renders for Rouvy. Rouvy supports + // (rouvyMdns, zwiftBle), so this captures ZwiftMdnsTile (Network, over the + // Rouvy mDNS emulator) + ZwiftTile (Bluetooth) — NOT the OBC tiles — both in + // their off / not-yet-connected state. + testGoldens('rouvy-connection-methods', (WidgetTester tester) async { + setActiveApp(Rouvy()); + await shootConnectionMethods(tester, 'rouvy-connection-methods'); + }); + + // The trainer-app picker with TrainingPeaks selected. Note: TrainingPeaks's + // app name is "TrainingPeaks Virtual", so the closed Select (and the + // connection-method descriptions below) show "TrainingPeaks Virtual". + testGoldens('trainingpeaks-trainer-select', (WidgetTester tester) async { + setActiveApp(TrainingPeaks()); + const k = ValueKey('shot'); + await shootLocalized( + tester, + 'trainingpeaks-trainer-select', + () => BikeControlApp( + customChild: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: RepaintBoundary( + key: k, + child: TrainerAppSelect(onUpdate: () {}, showRealName: true), + ), + ), + ), + ), + capture: () => find.byKey(k), + ); + }); + + // The connection methods the live app renders for TrainingPeaks. TrainingPeaks + // supports (obpBle, obpDirCon), so this captures OpenBikeControlMdnsTile + // (Network) + OpenBikeControlBluetoothTile (Bluetooth), both off. Descriptions + // read "Lets TrainingPeaks Virtual connect…". + testGoldens('trainingpeaks-connection-methods', (WidgetTester tester) async { + setActiveApp(TrainingPeaks()); + await shootConnectionMethods(tester, 'trainingpeaks-connection-methods'); + }); + + // --- Overview / main screen (website setup-guide step 1) --------------------- + // A clean capture of the app's OVERVIEW page (Controllers card + Trainer + // Connection) showing ONLY a single connected controller with its REAL product + // name (not the anonymized "Controller" the App-Store `Device` shot uses). No + // device frame, no marketing title banner — just the app's own UI inside the + // frameless `noFrame` ScreenshotApp, localized one shot per locale. + // + // Two tricks make this work: + // * We render the full `Navigation()` (the overview Scaffold + its AppBar) + // while `screenshotMode == true`, so `Navigation.initState` → + // `core.logic.startEnabledConnectionMethod()` early-returns (no BLE/mDNS + // emulators start) and the page is laid out without any real connection. + // * To get the REAL device name we flip `screenshotMode` off *synchronously* + // for just the final `pump()` that produces the captured frame, then + // restore it. e.g. `ZwiftClickV2.toString()` is read during that build and + // returns "Zwift Click V2"; every async callback that re-reads the flag + // (timers, the connection-init scan) runs on a later microtask, by which + // point it is `true` again, so nothing scans. Harmless for the controllers + // whose names are never anonymized. + // + // `core.connection.devices` is reduced to exactly the given controller + the + // smart-trainer proxy (so the Trainer Connection card shows a connected + // trainer). The active trainer app is MyWhoosh (the harness default). + // + // The frameless `noFrame` size entry's logical width (~367 px) is just narrow + // enough that the AppBar wraps the "BikeControl" title to two lines. The + // overview here is rendered slightly wider — `overviewNoFrameSize`, ~500 + // logical px — so the title fits on one line while staying well under the + // overview's 800-logical-px two-column breakpoint (so it remains a phone-style + // single-column portrait main screen). + // + // Captured via the Navigation Scaffold's existing `overviewScreenshotKey` + // RepaintBoundary → tight, no surrounding chrome. + final overviewNf = sizes.firstWhere((s) => s.type == DeviceType.noFrame); + // Widen just the overview shots so "BikeControl" no longer wraps. 1500 device + // px / pixelRatio 3 = 500 logical px — comfortably below the 800-logical-px + // two-column breakpoint, so it stays single-column portrait. + final overviewNoFrameSize = Size(1500, overviewNf.size.height); + + Future shootOverview( + WidgetTester tester, + String scene, + BaseDevice overviewDevice, + ) async { + const overviewLocales = ['en', 'de', 'es', 'fr', 'it']; + + // Only the given controller + the smart-trainer proxy. + final savedDevices = core.connection.devices.toList(); + core.connection.devices + ..clear() + ..addAll([overviewDevice, proxy]); + core.connection.hasDevices.value = true; + // core.settings.reset() (in main) clears this, so re-assert the Base version + // is active — otherwise the overview shows the "trial available" IAP banner. + IAPManager.instance.isPurchased.value = true; + + final savedScreenshotMode = screenshotMode; + try { + for (final loc in overviewLocales) { + await AppLocalizations.load(Locale(loc)); + screenshotLocale = Locale(loc); + await tester.pumpWidget( + ScreenshotApp( + locale: Locale(loc), + device: ScreenshotDevice( + // Android, never the entry's Windows platform (advapi32.dll can't + // load on a macOS test host). + platform: TargetPlatform.android, + resolution: overviewNoFrameSize, + pixelRatio: 3, + goldenSubFolder: 'iphoneScreenshots/', + frameBuilder: + ({ + required ScreenshotDevice device, + required ScreenshotFrameColors? frameColors, + required Widget child, + }) => CustomFrame(platform: DeviceType.noFrame, title: '', device: device, child: child), + ), + // Full overview Scaffold (header + Controllers card + Trainer + // Connection). screenshotMode is still true here, so initState does + // not start any connection method. + home: BikeControlApp(customChild: Navigation()), + ), + ); + await tester.pump(); + await tester.loadAssets(); + // Flip screenshotMode off only for the synchronous build/pump that + // produces the captured frame, so the Controllers card header shows the + // real product name, then restore it before any async work continues. + // initState already ran (with the flag true → no connection method + // started); mark the mounted OverviewPage element dirty so only its + // build() re-runs with the flag off, picking up the real device name. + screenshotMode = false; + tester.element(find.byType(OverviewPage)).markNeedsBuild(); + await tester.pump(); + try { + await expectLater( + find.byKey(overviewScreenshotKey), + matchesGoldenFile('../screenshots/$loc/$scene.png'), + ); + } finally { + screenshotMode = savedScreenshotMode; + await tester.pump(); + } + } + } finally { + screenshotMode = savedScreenshotMode; + core.connection.devices + ..clear() + ..addAll(savedDevices); + core.connection.hasDevices.value = core.connection.devices.isNotEmpty; + } + } + + testGoldens('overview-zwift-click', (WidgetTester tester) async { + await shootOverview(tester, 'overview-zwift-click', zwiftClick); + }); + + testGoldens('overview-zwift-click-v2', (WidgetTester tester) async { + await shootOverview(tester, 'overview-zwift-click-v2', device); + }); + + testGoldens('overview-zwift-ride', (WidgetTester tester) async { + await shootOverview(tester, 'overview-zwift-ride', zwiftRide); + }); + + testGoldens('overview-zwift-play', (WidgetTester tester) async { + await shootOverview(tester, 'overview-zwift-play', zwiftPlay); + }); + + testGoldens('overview-cycplus-bc2', (WidgetTester tester) async { + await shootOverview(tester, 'overview-cycplus-bc2', cycplusBc2); + }); + + testGoldens('overview-thinkrider-vs200', (WidgetTester tester) async { + await shootOverview(tester, 'overview-thinkrider-vs200', thinkriderVs200); + }); } diff --git a/test/services/debug_diagnostics_test.dart b/test/services/debug_diagnostics_test.dart new file mode 100644 index 00000000..fed93e54 --- /dev/null +++ b/test/services/debug_diagnostics_test.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:bike_control/services/debug_diagnostics.dart'; +import 'package:bike_control/services/mdns_discovery_scan.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:prop/utils/advertised_service_registry.dart'; +import 'package:prop/utils/network_address.dart'; + +void main() { + test('toText renders every section with the expected markers', () { + final diag = DebugDiagnostics( + advertised: const [ + AdvertisedRecord( + name: 'BikeControl', + type: '_openbikecontrol._tcp', + port: 36867, + address: '192.168.1.9', + txt: {'version': '0x01', 'name': 'BikeControl', 'id': '1337'}, + ), + ], + backend: 'responder', + hostLabel: 'bikecontrol-3f2a', + holdsMulticastLock: true, + discovered: const [ + DiscoveredMdnsService( + type: '_wahoo-fitness-tnp._tcp', + name: 'BikeControl', + host: '192.168.1.9', + port: 36867, + txt: {}, + isSelf: true, + ), + ], + discoveryRan: true, + addressReport: AddressPickReport( + chosen: InternetAddress('192.168.1.9'), + candidates: const [ + AddressCandidate(interfaceName: 'en0', address: '192.168.1.9', score: 40, isVirtual: false), + AddressCandidate(interfaceName: 'utun0', address: '10.2.0.2', score: -60, isVirtual: true), + ], + ), + servers: const [ + TcpServerInfo(label: 'OpenBikeControl', port: 36867, listening: true, hasClient: true), + ], + permissions: const PermissionsSnapshot( + localNetworkInferred: true, + ), + ); + + final text = diag.toText(); + + expect(text, contains('Advertised by this device')); + expect(text, contains('_openbikecontrol._tcp "BikeControl" 192.168.1.9:36867')); + // TXT entries are rendered alphabetically by key. + expect(text, contains('txt: id=1337, name=BikeControl, version=0x01')); + expect(text, contains('host: bikecontrol-3f2a.local')); + expect(text, contains('multicast-lock: held')); + expect(text, contains('(this device)')); + expect(text, contains('en0/192.168.1.9 = 40 (advertised)')); + expect(text, contains('utun0/10.2.0.2 = -60 (virtual)')); + expect(text, contains('OpenBikeControl :36867 listening · 1 client')); + expect(text, contains('ios-local-network=inferred-ok')); + }); + + test('toText marks discovery as skipped when it did not run', () { + final diag = DebugDiagnostics( + advertised: const [], + backend: 'nsd', + hostLabel: null, + holdsMulticastLock: false, + discovered: const [], + discoveryRan: false, + addressReport: const AddressPickReport(chosen: null, candidates: []), + servers: const [], + permissions: const PermissionsSnapshot(localNetworkInferred: null), + ); + + expect(diag.toText(), contains('Discovered on network:')); + expect(diag.toText(), contains('(skipped)')); + }); +} diff --git a/test/services/mdns_discovery_scan_test.dart b/test/services/mdns_discovery_scan_test.dart new file mode 100644 index 00000000..10acc1e4 --- /dev/null +++ b/test/services/mdns_discovery_scan_test.dart @@ -0,0 +1,34 @@ +import 'dart:typed_data'; + +import 'package:bike_control/services/mdns_discovery_scan.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nsd/nsd.dart' as nsd; + +void main() { + test('fromService decodes txt, carries host/port and self flag', () { + final service = nsd.Service( + name: 'KICKR ABCD', + type: '_wahoo-fitness-tnp._tcp', + port: 36866, + txt: { + 'serial-number': Uint8List.fromList('1234'.codeUnits), + 'version': Uint8List.fromList([0x01]), + }, + ); + + final d = DiscoveredMdnsService.fromService( + service, + host: '192.168.1.50', + port: 36866, + isSelf: false, + ); + + expect(d.name, 'KICKR ABCD'); + expect(d.type, '_wahoo-fitness-tnp._tcp'); + expect(d.host, '192.168.1.50'); + expect(d.port, 36866); + expect(d.isSelf, isFalse); + expect(d.txt['serial-number'], '1234'); + expect(d.txt['version'], '0x01'); + }); +} diff --git a/test/services/overlay/ios_pip_controller_test.dart b/test/services/overlay/ios_pip_controller_test.dart new file mode 100644 index 00000000..3e80ce6f --- /dev/null +++ b/test/services/overlay/ios_pip_controller_test.dart @@ -0,0 +1,61 @@ +import 'package:bike_control/services/overlay/ios_pip_controller.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('bike_control/pip_ios'); + final calls = []; + + setUp(() { + calls.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'isSupported') return true; + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('isSupported returns the native boolean', () async { + final pip = IosPipController(); + expect(await pip.isSupported(), true); + expect(calls.single.method, 'isSupported'); + }); + + test('start forwards the state map', () async { + final pip = IosPipController(); + await pip.start({'gear': 7, 'maxGear': 12}); + expect(calls.single.method, 'start'); + expect(calls.single.arguments, {'gear': 7, 'maxGear': 12}); + }); + + test('update forwards the state map', () async { + final pip = IosPipController(); + await pip.update({'gear': 8}); + expect(calls.single.method, 'update'); + expect(calls.single.arguments, {'gear': 8}); + }); + + test('stop invokes stop with no arguments', () async { + final pip = IosPipController(); + await pip.stop(); + expect(calls.single.method, 'stop'); + expect(calls.single.arguments, isNull); + }); + + test('isSupported returns false when the channel throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'boom'); + }); + final pip = IosPipController(); + expect(await pip.isSupported(), false); + }); +} diff --git a/test/services/overlay/overlay_state_map_test.dart b/test/services/overlay/overlay_state_map_test.dart new file mode 100644 index 00000000..d970c5db --- /dev/null +++ b/test/services/overlay/overlay_state_map_test.dart @@ -0,0 +1,49 @@ +import 'package:bike_control/services/overlay/overlay_state.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:prop/emulators/definitions/fitness_bike_definition.dart'; + +void main() { + group('overlayStateToActivityMap', () { + test('maps sim mode and omits null optional metrics', () { + final s = TrainerOverlayState( + gear: 7, + maxGear: 12, + gearRatio: 2.04, + mode: TrainerMode.simMode, + powerW: null, + cadenceRpm: null, + ergTargetW: null, + fields: {OverlayField.gearRatio}, + ); + final m = overlayStateToActivityMap(s); + expect(m['gear'], 7); + expect(m['maxGear'], 12); + expect(m['mode'], 'sim'); + expect(m['gearRatio'], 2.04); + expect(m['showGearRatio'], true); + expect(m['showPower'], false); + expect(m.containsKey('powerW'), false); + expect(m.containsKey('cadenceRpm'), false); + expect(m.containsKey('ergTargetW'), false); + }); + + test('maps erg mode and includes present optional metrics', () { + final s = TrainerOverlayState( + gear: 1, + maxGear: 24, + gearRatio: 1.0, + mode: TrainerMode.ergMode, + powerW: 210, + cadenceRpm: 88, + ergTargetW: 250, + fields: {OverlayField.power, OverlayField.cadence, OverlayField.ergTarget}, + ); + final m = overlayStateToActivityMap(s); + expect(m['mode'], 'erg'); + expect(m['powerW'], 210); + expect(m['cadenceRpm'], 88); + expect(m['ergTargetW'], 250); + expect(m['showErgTarget'], true); + }); + }); +} diff --git a/test/services/screen_recording/screen_recording_service_test.dart b/test/services/screen_recording/screen_recording_service_test.dart new file mode 100644 index 00000000..e9c6d3b3 --- /dev/null +++ b/test/services/screen_recording/screen_recording_service_test.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:bike_control/services/screen_recording/screen_recording_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// In-memory backend so the service's toggle/state logic is testable with no OS. +class FakeScreenRecorderBackend implements ScreenRecorderBackend { + bool available = true; + bool permission = true; + bool startResult = true; + String? stopPath = '/tmp/fake.mp4'; + int startCalls = 0; + int stopCalls = 0; + + @override + Future isAvailable() async => available; + @override + Future ensurePermission() async => permission; + /// When set, start() awaits this before returning, so a test can hold the + /// service in the `starting` state. + Completer? startGate; + + @override + Future start() async { + startCalls++; + if (startGate != null) await startGate!.future; + return startResult; + } + + @override + Future stop() async { + stopCalls++; + return stopPath; + } +} + +void main() { + late FakeScreenRecorderBackend backend; + late ScreenRecordingService service; + + setUp(() { + backend = FakeScreenRecorderBackend(); + service = ScreenRecordingService(backend: backend); + }); + + test('starts idle and reports availability from the backend', () async { + expect(service.state.value, ScreenRecordingState.idle); + expect(await service.isAvailable, isTrue); + backend.available = false; + expect(await service.isAvailable, isFalse); + }); + + test('toggle from idle starts recording', () async { + final result = await service.toggle(); + expect(result.ok, isTrue); + expect(result.startedRecording, isTrue); + expect(service.state.value, ScreenRecordingState.recording); + expect(service.isRecording, isTrue); + expect(backend.startCalls, 1); + }); + + test('toggle while recording stops and returns the saved path', () async { + await service.toggle(); // start + final result = await service.toggle(); // stop + expect(result.ok, isTrue); + expect(result.startedRecording, isFalse); + expect(result.savedPath, '/tmp/fake.mp4'); + expect(service.state.value, ScreenRecordingState.idle); + expect(service.isRecording, isFalse); + expect(backend.stopCalls, 1); + }); + + test('toggle returns failure and stays idle when start fails', () async { + backend.startResult = false; + final result = await service.toggle(); + expect(result.ok, isFalse); + expect(service.state.value, ScreenRecordingState.idle); + }); + + test('toggle returns failure when permission denied, without starting', () async { + backend.permission = false; + final result = await service.toggle(); + expect(result.ok, isFalse); + expect(result.startedRecording, isFalse); + expect(backend.startCalls, 0); + expect(service.state.value, ScreenRecordingState.idle); + }); + + test('toggle on unsupported backend reports unsupported', () async { + backend.available = false; + final result = await service.toggle(); + expect(result.ok, isFalse); + expect(result.startedRecording, isFalse); + expect(service.state.value, ScreenRecordingState.unsupported); + }); + + test('toggle completes without throwing when backend start() throws', () async { + final throwingBackend = _ThrowingScreenRecorderBackend(); + final throwingService = ScreenRecordingService(backend: throwingBackend); + final result = await throwingService.toggle(); + expect(result.ok, isFalse); + expect(throwingService.state.value, ScreenRecordingState.error); + }); + + test('ignores a re-entrant toggle while a start is in flight', () async { + backend.startGate = Completer(); + + final first = service.toggle(); // enters 'starting', then blocks in start() + // Let it advance through isAvailable/ensurePermission into the gated start(). + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + expect(service.state.value, ScreenRecordingState.starting); + + final second = await service.toggle(); // must be ignored, not a 2nd start + expect(second.ok, isFalse); + expect(backend.startCalls, 1, reason: 'the in-flight guard prevents a second start'); + + backend.startGate!.complete(); + await first; + expect(service.state.value, ScreenRecordingState.recording); + expect(backend.startCalls, 1); + }); +} + +/// Backend whose start() always throws, to verify the never-throws contract. +class _ThrowingScreenRecorderBackend implements ScreenRecorderBackend { + @override + Future isAvailable() async => true; + @override + Future ensurePermission() async => true; + @override + Future start() async => throw Exception('simulated backend crash'); + @override + Future stop() async => null; +} diff --git a/test/widgets/diagnostics_section_test.dart b/test/widgets/diagnostics_section_test.dart new file mode 100644 index 00000000..fe1bcf11 --- /dev/null +++ b/test/widgets/diagnostics_section_test.dart @@ -0,0 +1,41 @@ +import 'package:bike_control/services/debug_diagnostics.dart'; +import 'package:bike_control/widgets/diagnostics_section.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:prop/utils/network_address.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +DebugDiagnostics _fixture() => const DebugDiagnostics( + advertised: [], + backend: 'nsd', + hostLabel: null, + holdsMulticastLock: false, + discovered: [], + discoveryRan: true, + addressReport: AddressPickReport(chosen: null, candidates: []), + servers: [], + permissions: PermissionsSnapshot(localNetworkInferred: true), +); + +void main() { + testWidgets('renders diagnostics text and fires refresh', (tester) async { + var refreshed = 0; + await tester.pumpWidget( + ShadcnApp( + home: Scaffold( + child: DiagnosticsSection( + diagnostics: _fixture(), + scanning: false, + onRefresh: () => refreshed++, + ), + ), + ), + ); + + expect(find.textContaining('Diagnostics:'), findsOneWidget); + expect(find.text('Diagnostics'), findsOneWidget); // header + + await tester.tap(find.byKey(const ValueKey('diagnostics-refresh'))); + await tester.pump(); + expect(refreshed, 1); + }); +} diff --git a/test/widgets/overlay/trainer_overlay_view_test.dart b/test/widgets/overlay/trainer_overlay_view_test.dart index c2467da6..77f4403a 100644 --- a/test/widgets/overlay/trainer_overlay_view_test.dart +++ b/test/widgets/overlay/trainer_overlay_view_test.dart @@ -13,14 +13,17 @@ void main() { int? cadenceRpm = 86, Set fields = const {OverlayField.power, OverlayField.cadence}, TrainerMode mode = TrainerMode.simMode, + bool frontShiftEnabled = false, + bool frontRingLarge = false, }) { return ValueNotifier(TrainerOverlayState( gear: gear, maxGear: maxGear, gearRatio: 2.43, mode: mode, powerW: powerW, cadenceRpm: cadenceRpm, ergTargetW: null, fields: fields, + frontShiftEnabled: frontShiftEnabled, frontRingLarge: frontRingLarge, )); } - testWidgets('renders gear N / M and mode pill', (tester) async { + testWidgets('renders gear/total and mode pill', (tester) async { await tester.pumpWidget( ShadcnApp( home: Scaffold( @@ -28,10 +31,25 @@ void main() { ), ), ); - expect(find.text('14 / 24'), findsOneWidget); + expect(find.text('14/24'), findsOneWidget); expect(find.text('SIM'), findsOneWidget); }); + testWidgets('renders 2×N position notation when front shift is on', (tester) async { + await tester.pumpWidget( + ShadcnApp( + home: Scaffold( + child: TrainerOverlayView( + state: mkState(frontShiftEnabled: true, frontRingLarge: true), + onModeToggle: null, + ), + ), + ), + ); + expect(find.text('2×14'), findsOneWidget); + expect(find.text('14/24'), findsNothing); + }); + testWidgets('hides power when not selected', (tester) async { await tester.pumpWidget( ShadcnApp( diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 3bd74bd9..2c4073c5 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -11,12 +11,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -35,6 +37,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); FlutterVolumeControllerPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterVolumeControllerPluginCApi")); + GalPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GalPluginCApi")); GamepadsWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("GamepadsWindowsPluginCApi")); KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar( @@ -47,6 +51,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("NsdWindowsPluginCApi")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + ScreenRecorderPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRecorderPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 8cff4d1a..db1313e4 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -8,12 +8,14 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_screen_capture flutter_secure_storage_windows flutter_volume_controller + gal gamepads_windows keypress_simulator_windows media_key_detector_windows multi_window_native nsd_windows permission_handler_windows + screen_recorder screen_retriever_windows share_plus universal_ble