From e66ae575b29262b7970b269e3431cd68b08a9859 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:52:49 +0000 Subject: [PATCH 1/3] Initial plan From f0541c3da1d9bdddb4bfb5bf488ccbd19a84a888 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:58:35 +0000 Subject: [PATCH 2/3] feat: expose prepare() method for iOS haptic warmup Co-authored-by: nohli <43643339+nohli@users.noreply.github.com> --- .../haptic_feedback/HapticFeedbackPlugin.kt | 24 +++--- .../HapticFeedbackPlugin.swift | 84 ++++++++++++------- lib/src/haptic_feedback_method_channel.dart | 5 ++ .../haptic_feedback_platform_interface.dart | 8 ++ lib/src/haptics.dart | 18 ++++ test/haptic_feedback_method_channel_test.dart | 16 ++++ test/haptic_feedback_test.dart | 24 +++++- test/support/mock_platforms.dart | 9 ++ 8 files changed, 146 insertions(+), 42 deletions(-) diff --git a/android/src/main/kotlin/io/achim/haptic_feedback/HapticFeedbackPlugin.kt b/android/src/main/kotlin/io/achim/haptic_feedback/HapticFeedbackPlugin.kt index 948fde0..216913d 100644 --- a/android/src/main/kotlin/io/achim/haptic_feedback/HapticFeedbackPlugin.kt +++ b/android/src/main/kotlin/io/achim/haptic_feedback/HapticFeedbackPlugin.kt @@ -58,17 +58,19 @@ class HapticFeedbackPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } override fun onMethodCall(call: MethodCall, result: Result) { - if (call.method == "canVibrate") { - canVibrate(result) - } else { - val pattern = Pattern.values().find { it.name == call.method } - if (pattern != null) { - val args = call.arguments as? Map<*, *> - val usage = Usage.fromArguments(args) - val useAndroidHapticConstants = (args?.get("useAndroidHapticConstants") as? Boolean) ?: false - vibratePattern(pattern, usage, useAndroidHapticConstants, result) - } else { - result.notImplemented() + when (call.method) { + "canVibrate" -> canVibrate(result) + "prepare" -> result.success(null) + else -> { + val pattern = Pattern.values().find { it.name == call.method } + if (pattern != null) { + val args = call.arguments as? Map<*, *> + val usage = Usage.fromArguments(args) + val useAndroidHapticConstants = (args?.get("useAndroidHapticConstants") as? Boolean) ?: false + vibratePattern(pattern, usage, useAndroidHapticConstants, result) + } else { + result.notImplemented() + } } } } diff --git a/ios/haptic_feedback/Sources/haptic_feedback/HapticFeedbackPlugin.swift b/ios/haptic_feedback/Sources/haptic_feedback/HapticFeedbackPlugin.swift index 52017b0..7801d6d 100644 --- a/ios/haptic_feedback/Sources/haptic_feedback/HapticFeedbackPlugin.swift +++ b/ios/haptic_feedback/Sources/haptic_feedback/HapticFeedbackPlugin.swift @@ -2,6 +2,22 @@ import CoreHaptics import Flutter public class HapticFeedbackPlugin: NSObject, FlutterPlugin { + private let notificationGenerator = UINotificationFeedbackGenerator() + private let lightImpactGenerator = UIImpactFeedbackGenerator(style: .light) + private let mediumImpactGenerator = UIImpactFeedbackGenerator(style: .medium) + private let heavyImpactGenerator = UIImpactFeedbackGenerator(style: .heavy) + private var rigidImpactGenerator: UIImpactFeedbackGenerator? + private var softImpactGenerator: UIImpactFeedbackGenerator? + private let selectionGenerator = UISelectionFeedbackGenerator() + + override init() { + super.init() + if #available(iOS 13.0, *) { + rigidImpactGenerator = UIImpactFeedbackGenerator(style: .rigid) + softImpactGenerator = UIImpactFeedbackGenerator(style: .soft) + } + } + public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "haptic_feedback", binaryMessenger: registrar.messenger()) let instance = HapticFeedbackPlugin() @@ -12,32 +28,35 @@ public class HapticFeedbackPlugin: NSObject, FlutterPlugin { switch call.method { case "canVibrate": canVibrate(result: result) + case "prepare": + prepare(type: call.arguments as? String, result: result) case "success": - notification(type: .success, result: result) + notificationGenerator.notificationOccurred(.success) + result(nil) case "warning": - notification(type: .warning, result: result) + notificationGenerator.notificationOccurred(.warning) + result(nil) case "error": - notification(type: .error, result: result) + notificationGenerator.notificationOccurred(.error) + result(nil) case "light": - impact(style: .light, result: result) + lightImpactGenerator.impactOccurred() + result(nil) case "medium": - impact(style: .medium, result: result) + mediumImpactGenerator.impactOccurred() + result(nil) case "heavy": - impact(style: .heavy, result: result) + heavyImpactGenerator.impactOccurred() + result(nil) case "rigid": - if #available(iOS 13.0, *) { - impact(style: .rigid, result: result) - } else { - impact(style: .medium, result: result) - } + (rigidImpactGenerator ?? mediumImpactGenerator).impactOccurred() + result(nil) case "soft": - if #available(iOS 13.0, *) { - impact(style: .soft, result: result) - } else { - impact(style: .light, result: result) - } + (softImpactGenerator ?? lightImpactGenerator).impactOccurred() + result(nil) case "selection": - selection(result: result) + selectionGenerator.selectionChanged() + result(nil) default: result(FlutterMethodNotImplemented) } @@ -57,18 +76,25 @@ public class HapticFeedbackPlugin: NSObject, FlutterPlugin { result(false) } - private func notification(type: UINotificationFeedbackGenerator.FeedbackType, result: @escaping FlutterResult) { - UINotificationFeedbackGenerator().notificationOccurred(type) - result(nil) - } - - private func impact(style: UIImpactFeedbackGenerator.FeedbackStyle, result: @escaping FlutterResult) { - UIImpactFeedbackGenerator(style: style).impactOccurred() - result(nil) - } - - private func selection(result: @escaping FlutterResult) { - UISelectionFeedbackGenerator().selectionChanged() + private func prepare(type: String?, result: @escaping FlutterResult) { + switch type { + case "success", "warning", "error": + notificationGenerator.prepare() + case "light": + lightImpactGenerator.prepare() + case "medium": + mediumImpactGenerator.prepare() + case "heavy": + heavyImpactGenerator.prepare() + case "rigid": + (rigidImpactGenerator ?? mediumImpactGenerator).prepare() + case "soft": + (softImpactGenerator ?? lightImpactGenerator).prepare() + case "selection": + selectionGenerator.prepare() + default: + break + } result(nil) } } diff --git a/lib/src/haptic_feedback_method_channel.dart b/lib/src/haptic_feedback_method_channel.dart index 01b0f86..74a3ef6 100644 --- a/lib/src/haptic_feedback_method_channel.dart +++ b/lib/src/haptic_feedback_method_channel.dart @@ -29,4 +29,9 @@ class MethodChannelHapticFeedback extends HapticFeedbackPlatform { return await methodChannel.invokeMethod(type.name, arguments); } + + @override + Future prepare(HapticsType type) async { + return await methodChannel.invokeMethod('prepare', type.name); + } } diff --git a/lib/src/haptic_feedback_platform_interface.dart b/lib/src/haptic_feedback_platform_interface.dart index e081615..8756f79 100644 --- a/lib/src/haptic_feedback_platform_interface.dart +++ b/lib/src/haptic_feedback_platform_interface.dart @@ -44,4 +44,12 @@ abstract class HapticFeedbackPlatform extends PlatformInterface { 'Use the implementation method of MethodChannelHapticFeedback.', ); } + + /// Prepares the haptic engine for the given [type] on iOS, reducing latency + /// for the next haptic event. This is a no-op on Android. + Future prepare(HapticsType type) { + throw UnsupportedError( + 'Use the implementation method of MethodChannelHapticFeedback.', + ); + } } diff --git a/lib/src/haptics.dart b/lib/src/haptics.dart index f4a8d92..3d331aa 100644 --- a/lib/src/haptics.dart +++ b/lib/src/haptics.dart @@ -46,4 +46,22 @@ class Haptics { useAndroidHapticConstants: useAndroidHapticConstants, ); } + + /// Prepares the haptic engine for a haptic event of [type], reducing the + /// latency of the first haptic in a session on iOS. + /// + /// On iOS, this calls `prepare()` on the underlying `UIFeedbackGenerator`, + /// warming up the Taptic Engine ahead of the actual feedback event. The + /// engine stays warm for a few seconds. Call this method when you know + /// a haptic event is imminent (e.g., when a screen loads or a gesture + /// begins). + /// + /// This method is a no-op on Android and unsupported platforms. + static Future prepare(HapticsType type) async { + if (!isPlatformSupported) { + return; + } + + return HapticFeedbackPlatform.instance.prepare(type); + } } diff --git a/test/haptic_feedback_method_channel_test.dart b/test/haptic_feedback_method_channel_test.dart index ebe137a..4cc4d2f 100644 --- a/test/haptic_feedback_method_channel_test.dart +++ b/test/haptic_feedback_method_channel_test.dart @@ -63,4 +63,20 @@ void main() { expect(lastMethodCall?.method, 'heavy'); expect(lastMethodCall?.arguments, {'useAndroidHapticConstants': true}); }); + + test('prepare invokes prepare method with type name', () async { + await platform.prepare(HapticsType.light); + + expect(lastMethodCall?.method, 'prepare'); + expect(lastMethodCall?.arguments, 'light'); + }); + + test('prepare forwards each haptics type correctly', () async { + for (final type in HapticsType.values) { + await platform.prepare(type); + + expect(lastMethodCall?.method, 'prepare'); + expect(lastMethodCall?.arguments, type.name); + } + }); } diff --git a/test/haptic_feedback_test.dart b/test/haptic_feedback_test.dart index 5eb06b7..87c7930 100644 --- a/test/haptic_feedback_test.dart +++ b/test/haptic_feedback_test.dart @@ -49,8 +49,7 @@ void main() { expect(recordingPlatform.lastUseAndroidHapticConstants, false); }); - test( - 'vibrate forwards useAndroidHapticConstants to the platform implementation', + test('vibrate forwards useAndroidHapticConstants to the platform implementation', () async { final recordingPlatform = RecordingHapticFeedbackPlatform(); HapticFeedbackPlatform.instance = recordingPlatform; @@ -63,4 +62,25 @@ void main() { expect(recordingPlatform.lastType, HapticsType.success); expect(recordingPlatform.lastUseAndroidHapticConstants, false); }); + + test('prepare forwards type to the platform implementation', () async { + final recordingPlatform = RecordingHapticFeedbackPlatform(); + HapticFeedbackPlatform.instance = recordingPlatform; + + await Haptics.prepare(HapticsType.light); + + expect(recordingPlatform.lastPreparedType, HapticsType.light); + }); + + test('prepare performs nothing on unsupported platforms', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + final recordingPlatform = RecordingHapticFeedbackPlatform(); + HapticFeedbackPlatform.instance = recordingPlatform; + + await Haptics.prepare(HapticsType.heavy); + + expect(recordingPlatform.lastPreparedType, isNull); + }); } diff --git a/test/support/mock_platforms.dart b/test/support/mock_platforms.dart index ffa2afb..6a38abc 100644 --- a/test/support/mock_platforms.dart +++ b/test/support/mock_platforms.dart @@ -19,6 +19,9 @@ class MockHapticFeedbackPlatform HapticsUsage? usage, bool useAndroidHapticConstants = false, }) async {} + + @override + Future prepare(HapticsType type) async {} } class RecordingHapticFeedbackPlatform @@ -27,6 +30,7 @@ class RecordingHapticFeedbackPlatform HapticsType? lastType; HapticsUsage? lastUsage; bool? lastUseAndroidHapticConstants; + HapticsType? lastPreparedType; @override Future canVibrate() async { @@ -43,4 +47,9 @@ class RecordingHapticFeedbackPlatform lastUsage = usage; lastUseAndroidHapticConstants = useAndroidHapticConstants; } + + @override + Future prepare(HapticsType type) async { + lastPreparedType = type; + } } From 8e39c01195dfaf70a128529925a900ebb7c544bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 02:07:19 +0000 Subject: [PATCH 3/3] fix(ios): add import UIKit, replace nullable optionals with explicit #available check in init Co-authored-by: nohli <43643339+nohli@users.noreply.github.com> --- .../haptic_feedback/HapticFeedbackPlugin.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/ios/haptic_feedback/Sources/haptic_feedback/HapticFeedbackPlugin.swift b/ios/haptic_feedback/Sources/haptic_feedback/HapticFeedbackPlugin.swift index 7801d6d..e48bdf5 100644 --- a/ios/haptic_feedback/Sources/haptic_feedback/HapticFeedbackPlugin.swift +++ b/ios/haptic_feedback/Sources/haptic_feedback/HapticFeedbackPlugin.swift @@ -1,21 +1,25 @@ import CoreHaptics import Flutter +import UIKit public class HapticFeedbackPlugin: NSObject, FlutterPlugin { private let notificationGenerator = UINotificationFeedbackGenerator() private let lightImpactGenerator = UIImpactFeedbackGenerator(style: .light) private let mediumImpactGenerator = UIImpactFeedbackGenerator(style: .medium) private let heavyImpactGenerator = UIImpactFeedbackGenerator(style: .heavy) - private var rigidImpactGenerator: UIImpactFeedbackGenerator? - private var softImpactGenerator: UIImpactFeedbackGenerator? + private let rigidImpactGenerator: UIImpactFeedbackGenerator + private let softImpactGenerator: UIImpactFeedbackGenerator private let selectionGenerator = UISelectionFeedbackGenerator() override init() { - super.init() if #available(iOS 13.0, *) { rigidImpactGenerator = UIImpactFeedbackGenerator(style: .rigid) softImpactGenerator = UIImpactFeedbackGenerator(style: .soft) + } else { + rigidImpactGenerator = UIImpactFeedbackGenerator(style: .medium) + softImpactGenerator = UIImpactFeedbackGenerator(style: .light) } + super.init() } public static func register(with registrar: FlutterPluginRegistrar) { @@ -49,10 +53,10 @@ public class HapticFeedbackPlugin: NSObject, FlutterPlugin { heavyImpactGenerator.impactOccurred() result(nil) case "rigid": - (rigidImpactGenerator ?? mediumImpactGenerator).impactOccurred() + rigidImpactGenerator.impactOccurred() result(nil) case "soft": - (softImpactGenerator ?? lightImpactGenerator).impactOccurred() + softImpactGenerator.impactOccurred() result(nil) case "selection": selectionGenerator.selectionChanged() @@ -87,9 +91,9 @@ public class HapticFeedbackPlugin: NSObject, FlutterPlugin { case "heavy": heavyImpactGenerator.prepare() case "rigid": - (rigidImpactGenerator ?? mediumImpactGenerator).prepare() + rigidImpactGenerator.prepare() case "soft": - (softImpactGenerator ?? lightImpactGenerator).prepare() + softImpactGenerator.prepare() case "selection": selectionGenerator.prepare() default: