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