diff --git a/Cargo.toml b/Cargo.toml index 61dbf0f..709e40e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ dioxus-sdk-notification = { path = "packages/notification", version = "0.7.0" } dioxus-sdk-sync = { path = "packages/sync", version = "0.7.0" } dioxus-sdk-util = { path = "packages/util", version = "0.7.0" } dioxus-sdk-window = { path = "packages/window", version = "0.7.0" } +dioxus-sdk-haptics = { path = "packages/haptics", version = "0.7.0" } # Dioxus dioxus = "0.7.0" diff --git a/README.md b/README.md index 69971a8..56de0da 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ - [x] Channels - `dioxus-sdk-util` - [x] `use_root_scroll` +- `dioxus-sdk-haptics` - Android & iOS - [ ] Camera - [ ] WiFi - [ ] Bluetooth diff --git a/examples/haptics/Cargo.toml b/examples/haptics/Cargo.toml new file mode 100644 index 0000000..d3bdb33 --- /dev/null +++ b/examples/haptics/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "haptics-example" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +dioxus = { workspace = true } +dioxus-sdk-haptics = { workspace = true } + +[features] +default = ["android"] +android = [] +web = ["dioxus/web"] +desktop = ["dioxus/desktop"] diff --git a/examples/haptics/README.md b/examples/haptics/README.md new file mode 100644 index 0000000..b7451b9 --- /dev/null +++ b/examples/haptics/README.md @@ -0,0 +1,11 @@ +# haptics + +Learn how to use the `haptics` abstraction. + +Run: + +```dx serve --platorm android``` + +Or use the provided justfile to easily test on a real android device connected via ADB: + +```just android-haptics``` diff --git a/examples/haptics/justfile b/examples/haptics/justfile new file mode 100644 index 0000000..729bb8f --- /dev/null +++ b/examples/haptics/justfile @@ -0,0 +1,33 @@ +default: + @just --list + +android-haptics device='': + #!/usr/bin/env bash + set -euo pipefail + + serial='{{device}}' + + adb start-server >/dev/null + + if [[ -z "$serial" ]]; then + mapfile -t devices < <(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }') + + if [[ ${#devices[@]} -eq 0 ]]; then + echo 'No adb device is connected.' >&2 + echo 'Connect an Android device over USB and make sure USB debugging is enabled.' >&2 + exit 1 + fi + + if [[ ${#devices[@]} -gt 1 ]]; then + echo 'Multiple adb devices are connected. Pass a serial explicitly:' >&2 + echo ' just android-haptics ' >&2 + adb devices -l >&2 + exit 1 + fi + + serial="${devices[0]}" + fi + + echo "Using adb device: $serial" + adb -s "$serial" wait-for-device + dx run --platform android --device "$serial" diff --git a/examples/haptics/src/main.rs b/examples/haptics/src/main.rs new file mode 100644 index 0000000..8778557 --- /dev/null +++ b/examples/haptics/src/main.rs @@ -0,0 +1,111 @@ +use dioxus::prelude::*; +use dioxus_sdk_haptics::{ + impact_feedback, notification_feedback, selection_feedback, vibrate, ImpactFeedbackStyle, + NotificationFeedbackType, +}; + +fn main() { + launch(App); +} + +#[component] +fn App() -> Element { + let status = use_signal(|| "Tap a button to test haptics.".to_string()); + + rsx! { + div { + style: "max-width: 720px; margin: 0 auto; padding: 24px; font-family: sans-serif;", + h1 { "Dioxus Haptics Example" } + p { "Run this on an mobile device to exercise the haptics bridge." } + p { strong { "Status: " } "{status}" } + + section { + style: "margin-top: 24px;", + h2 { "Vibrate" } + div { + style: "display: flex; gap: 12px; flex-wrap: wrap;", + button { + onclick: move |_| run_action(status, "vibrate 25ms", map_result(vibrate(25))), + "25 ms" + } + button { + onclick: move |_| run_action(status, "vibrate 75ms", map_result(vibrate(75))), + "75 ms" + } + button { + onclick: move |_| run_action(status, "vibrate 150ms", map_result(vibrate(150))), + "150 ms" + } + } + } + + section { + style: "margin-top: 24px;", + h2 { "Impact Feedback" } + div { + style: "display: flex; gap: 12px; flex-wrap: wrap;", + button { + onclick: move |_| run_action(status, "impact light", map_result(impact_feedback(ImpactFeedbackStyle::Light))), + "Light" + } + button { + onclick: move |_| run_action(status, "impact medium", map_result(impact_feedback(ImpactFeedbackStyle::Medium))), + "Medium" + } + button { + onclick: move |_| run_action(status, "impact heavy", map_result(impact_feedback(ImpactFeedbackStyle::Heavy))), + "Heavy" + } + button { + onclick: move |_| run_action(status, "impact soft", map_result(impact_feedback(ImpactFeedbackStyle::Soft))), + "Soft" + } + button { + onclick: move |_| run_action(status, "impact rigid", map_result(impact_feedback(ImpactFeedbackStyle::Rigid))), + "Rigid" + } + } + } + + section { + style: "margin-top: 24px;", + h2 { "Notification Feedback" } + div { + style: "display: flex; gap: 12px; flex-wrap: wrap;", + button { + onclick: move |_| run_action(status, "notification success", map_result(notification_feedback(NotificationFeedbackType::Success))), + "Success" + } + button { + onclick: move |_| run_action(status, "notification warning", map_result(notification_feedback(NotificationFeedbackType::Warning))), + "Warning" + } + button { + onclick: move |_| run_action(status, "notification error", map_result(notification_feedback(NotificationFeedbackType::Error))), + "Error" + } + } + } + + section { + style: "margin-top: 24px;", + h2 { "Selection" } + button { + onclick: move |_| run_action(status, "selection feedback", map_result(selection_feedback())), + "Selection" + } + } + } + } +} + +fn run_action(mut status: Signal, label: &'static str, result: Result<(), String>) { + match result { + Ok(()) => status.set(format!("Triggered {label}.")), + Err(err) => status.set(format!("{label} failed: {err}")), + } +} + +fn map_result(result: Result<(), dioxus_sdk_haptics::HapticsError>) -> Result<(), String> { + result.map_err(|err| err.to_string()) +} diff --git a/packages/haptics/Cargo.toml b/packages/haptics/Cargo.toml new file mode 100644 index 0000000..64ec098 --- /dev/null +++ b/packages/haptics/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "dioxus-sdk-haptics" +version = "0.7.0" + +description = "Haptics utilities for Dioxus." +readme = "./README.md" +keywords = ["gui", "dioxus"] +categories = ["gui", "android", "ios"] + +edition.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +dioxus = { workspace = true } +cfg-if = { workspace = true } +serde = { workspace = true } + +[target.'cfg(target_os = "android")'.dependencies] +manganis = "0.7.6" + +[target.'cfg(target_os = "ios")'.dependencies] +manganis = "0.7.6" diff --git a/packages/haptics/README.md b/packages/haptics/README.md new file mode 100644 index 0000000..afe613d --- /dev/null +++ b/packages/haptics/README.md @@ -0,0 +1,43 @@ +# Dioxus Haptics +Haptics utilities for Dioxus. + +Heavily inspired by [tauri-plugin-haptics](https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/haptics) + +### Supports +- [ ] Web +- [ ] Windows +- [ ] Mac +- [ ] Linux +- [x] Android +- [x] iOs + +## Usage +Add `dioxus-sdk-haptics` to your `Cargo.toml`: +```toml +[dependencies] +dioxus-sdk-haptics = "0.7" +``` + +Example: +```rs +use dioxus::prelude::*; +use dioxus_sdk_haptics::vibrate; + +#[component] +fn App() -> Element { + let mut status = use_signal(|| "Tap the button to vibrate.".to_string()); + + rsx! { + div { + p { strong { "Status: " } "{status}" } + button { + onclick: move |_| match vibrate(25).map_err(|err| err.to_string()){ + Ok(()) => status.set(format!("Triggered 25ms.")), + Err(err) => status.set(format!("25ms failed: {err}")), + }, + "25 ms" + } + } + } +} +``` diff --git a/packages/haptics/android/build.gradle.kts b/packages/haptics/android/build.gradle.kts new file mode 100644 index 0000000..4417796 --- /dev/null +++ b/packages/haptics/android/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "dev.dioxus.sdk.haptics" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies {} diff --git a/packages/haptics/android/src/main/AndroidManifest.xml b/packages/haptics/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..042e61a --- /dev/null +++ b/packages/haptics/android/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt new file mode 100644 index 0000000..7769874 --- /dev/null +++ b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/HapticsPlugin.kt @@ -0,0 +1,75 @@ +package dev.dioxus.sdk.haptics + +import android.app.Activity +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import dev.dioxus.sdk.haptics.patterns.ImpactPatternHeavy +import dev.dioxus.sdk.haptics.patterns.ImpactPatternLight +import dev.dioxus.sdk.haptics.patterns.ImpactPatternMedium +import dev.dioxus.sdk.haptics.patterns.ImpactPatternRigid +import dev.dioxus.sdk.haptics.patterns.ImpactPatternSoft +import dev.dioxus.sdk.haptics.patterns.NotificationPatternError +import dev.dioxus.sdk.haptics.patterns.NotificationPatternSuccess +import dev.dioxus.sdk.haptics.patterns.NotificationPatternWarning +import dev.dioxus.sdk.haptics.patterns.Pattern +import dev.dioxus.sdk.haptics.patterns.SelectionPattern + +class HapticsPlugin(activity: Activity) { + private val appContext = activity.applicationContext + + private val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = + appContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + appContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + fun vibrate(duration: Long) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(duration) + } + } + + fun impactFeedback(style: String) { + vibratePattern( + when (style.lowercase()) { + "light" -> ImpactPatternLight + "heavy" -> ImpactPatternHeavy + "soft" -> ImpactPatternSoft + "rigid" -> ImpactPatternRigid + else -> ImpactPatternMedium + } + ) + } + + fun notificationFeedback(kind: String) { + vibratePattern( + when (kind.lowercase()) { + "warning" -> NotificationPatternWarning + "error" -> NotificationPatternError + else -> NotificationPatternSuccess + } + ) + } + + fun selectionFeedback() { + vibratePattern(SelectionPattern) + } + + private fun vibratePattern(pattern: Pattern) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createWaveform(pattern.timings, pattern.amplitudes, -1)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(pattern.oldSDKPattern, -1) + } + } +} diff --git a/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Impact.kt b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Impact.kt new file mode 100644 index 0000000..9c3a935 --- /dev/null +++ b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Impact.kt @@ -0,0 +1,35 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package dev.dioxus.sdk.haptics.patterns + +val ImpactPatternLight = Pattern( + longArrayOf(0, 50), + intArrayOf(0, 30), + longArrayOf(0, 20) +) + +val ImpactPatternMedium = Pattern( + longArrayOf(0, 43), + intArrayOf(0, 50), + longArrayOf(0, 43) +) + +val ImpactPatternHeavy = Pattern( + longArrayOf(0, 60), + intArrayOf(0, 70), + longArrayOf(0, 61) +) + +val ImpactPatternSoft = Pattern( + longArrayOf(0, 50), + intArrayOf(0, 30), + longArrayOf(0, 20) +) + +val ImpactPatternRigid = Pattern( + longArrayOf(0, 43), + intArrayOf(0, 50), + longArrayOf(0, 43) +) diff --git a/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Notification.kt b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Notification.kt new file mode 100644 index 0000000..59acb1e --- /dev/null +++ b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Notification.kt @@ -0,0 +1,23 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package dev.dioxus.sdk.haptics.patterns + +val NotificationPatternSuccess = Pattern( + longArrayOf(0, 40, 100, 40), + intArrayOf(0, 50, 0, 60), + longArrayOf(0, 40, 100, 40) +) + +val NotificationPatternWarning = Pattern( + longArrayOf(0, 40, 120, 60), + intArrayOf(0, 40, 0, 60), + longArrayOf(0, 40, 120, 60) +) + +val NotificationPatternError = Pattern( + longArrayOf(0, 60, 100, 40, 80, 50), + intArrayOf(0, 50, 0, 40, 0, 50), + longArrayOf(0, 60, 100, 40, 80, 50) +) diff --git a/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Pattern.kt b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Pattern.kt new file mode 100644 index 0000000..9e69e2d --- /dev/null +++ b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Pattern.kt @@ -0,0 +1,11 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package dev.dioxus.sdk.haptics.patterns + +class Pattern( + val timings: LongArray, + val amplitudes: IntArray, + val oldSDKPattern: LongArray +) {} diff --git a/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Selection.kt b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Selection.kt new file mode 100644 index 0000000..fc91574 --- /dev/null +++ b/packages/haptics/android/src/main/java/dev/dioxus/sdk/haptics/patterns/Selection.kt @@ -0,0 +1,11 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package dev.dioxus.sdk.haptics.patterns + +val SelectionPattern = Pattern( + timings = longArrayOf(0, 50), + amplitudes = intArrayOf(0, 30), + oldSDKPattern = longArrayOf(0, 70) +) diff --git a/packages/haptics/ios/.gitignore b/packages/haptics/ios/.gitignore new file mode 100644 index 0000000..24e5b0a --- /dev/null +++ b/packages/haptics/ios/.gitignore @@ -0,0 +1 @@ +.build diff --git a/packages/haptics/ios/Package.swift b/packages/haptics/ios/Package.swift new file mode 100644 index 0000000..2a07644 --- /dev/null +++ b/packages/haptics/ios/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.7 + +import PackageDescription + +let package = Package( + name: "haptics-plugin", + platforms: [ + .iOS(.v13) + ], + products: [ + .library( + name: "HapticsPlugin", + targets: ["HapticsPlugin"] + ) + ], + targets: [ + .target( + name: "HapticsPlugin", + path: "Sources/HapticsPlugin" + ) + ] +) diff --git a/packages/haptics/ios/Sources/HapticsPlugin/HapticsPlugin.swift b/packages/haptics/ios/Sources/HapticsPlugin/HapticsPlugin.swift new file mode 100644 index 0000000..4986ab8 --- /dev/null +++ b/packages/haptics/ios/Sources/HapticsPlugin/HapticsPlugin.swift @@ -0,0 +1,132 @@ +import AudioToolbox +import CoreHaptics +import Dispatch +import Foundation +import UIKit + +@objc(HapticsPlugin) +public final class HapticsPlugin: NSObject { + private lazy var lightImpactGenerator = UIImpactFeedbackGenerator(style: .light) + private lazy var mediumImpactGenerator = UIImpactFeedbackGenerator(style: .medium) + private lazy var heavyImpactGenerator = UIImpactFeedbackGenerator(style: .heavy) + private lazy var softImpactGenerator = UIImpactFeedbackGenerator(style: .soft) + private lazy var rigidImpactGenerator = UIImpactFeedbackGenerator(style: .rigid) + private lazy var notificationGenerator = UINotificationFeedbackGenerator() + private lazy var selectionGenerator = UISelectionFeedbackGenerator() + private lazy var hapticEngine: CHHapticEngine? = makeHapticEngine() + + @objc public func vibrate(_ duration: Int64) { + runOnMain { + self.vibrateOnMain(duration) + } + } + + @objc public func impactFeedback(_ style: String) { + runOnMain { + let generator = self.impactGenerator(from: style) + generator.prepare() + generator.impactOccurred() + } + } + + @objc public func notificationFeedback(_ kind: String) { + runOnMain { + self.notificationGenerator.prepare() + self.notificationGenerator.notificationOccurred(self.notificationType(from: kind)) + } + } + + @objc public func selectionFeedback() { + runOnMain { + self.selectionGenerator.prepare() + self.selectionGenerator.selectionChanged() + } + } + + private func runOnMain(_ action: @escaping () -> Void) { + if Thread.isMainThread { + action() + } else { + DispatchQueue.main.sync(execute: action) + } + } + + private func vibrateOnMain(_ duration: Int64) { + guard let engine = hapticEngine else { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + return + } + + do { + try engine.start() + + let intensity = CHHapticEventParameter( + parameterID: .hapticIntensity, + value: 1.0 + ) + let sharpness = CHHapticEventParameter( + parameterID: .hapticSharpness, + value: 1.0 + ) + let event = CHHapticEvent( + eventType: .hapticContinuous, + parameters: [intensity, sharpness], + relativeTime: 0.0, + duration: Double(duration) / 1000.0 + ) + let pattern = try CHHapticPattern(events: [event], parameters: []) + let player = try engine.makePlayer(with: pattern) + + try player.start(atTime: 0) + } catch { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + } + } + + private func makeHapticEngine() -> CHHapticEngine? { + guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { + return nil + } + + do { + let engine = try CHHapticEngine() + engine.resetHandler = { + do { + try engine.start() + } catch { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + } + } + try engine.start() + return engine + } catch { + return nil + } + } + + private func impactGenerator(from style: String) -> UIImpactFeedbackGenerator { + switch style { + case "light": + return lightImpactGenerator + case "heavy": + return heavyImpactGenerator + case "soft": + return softImpactGenerator + case "rigid": + return rigidImpactGenerator + default: + return mediumImpactGenerator + } + } + + private func notificationType(from kind: String) -> UINotificationFeedbackGenerator.FeedbackType { + switch kind { + case "warning": + return .warning + case "error": + return .error + default: + return .success + } + } +} diff --git a/packages/haptics/src/android.rs b/packages/haptics/src/android.rs new file mode 100644 index 0000000..965c6e1 --- /dev/null +++ b/packages/haptics/src/android.rs @@ -0,0 +1,50 @@ +#![allow(non_snake_case)] + +use crate::{ImpactFeedbackStyle, NotificationFeedbackType}; +use std::sync::OnceLock; + +mod ffi { + #[cfg(target_os = "android")] + #[manganis::ffi("android")] + extern "Kotlin" { + pub type HapticsPlugin; + + pub fn vibrate(this: &HapticsPlugin, duration: i64); + pub fn impactFeedback(this: &HapticsPlugin, style: String); + pub fn notificationFeedback(this: &HapticsPlugin, kind: String); + pub fn selectionFeedback(this: &HapticsPlugin); + } +} + +static PLUGIN: OnceLock> = OnceLock::new(); + +fn plugin() -> Result<&'static ffi::HapticsPlugin, String> { + PLUGIN + .get_or_init(ffi::HapticsPlugin::new) + .as_ref() + .map_err(Clone::clone) +} + +pub fn vibrate(duration: u32) -> Result<(), String> { + let plugin = plugin()?; + let _ = ffi::vibrate(plugin, duration as i64)?; + Ok(()) +} + +pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), String> { + let plugin = plugin()?; + let _ = ffi::impactFeedback(plugin, style.to_string())?; + Ok(()) +} + +pub fn notification_feedback(kind: NotificationFeedbackType) -> Result<(), String> { + let plugin = plugin()?; + let _ = ffi::notificationFeedback(plugin, kind.to_string())?; + Ok(()) +} + +pub fn selection_feedback() -> Result<(), String> { + let plugin = plugin()?; + let _ = ffi::selectionFeedback(plugin)?; + Ok(()) +} diff --git a/packages/haptics/src/ios.rs b/packages/haptics/src/ios.rs new file mode 100644 index 0000000..3b95235 --- /dev/null +++ b/packages/haptics/src/ios.rs @@ -0,0 +1,44 @@ +use crate::{ImpactFeedbackStyle, NotificationFeedbackType}; +use std::sync::OnceLock; + +mod ffi { + #[allow(non_snake_case)] + #[manganis::ffi("/ios")] + unsafe extern "Swift" { + pub type HapticsPlugin; + + pub fn vibrate(this: &HapticsPlugin, duration: i64); + pub fn impact_feedback(this: &HapticsPlugin, style: &str); + pub fn notification_feedback(this: &HapticsPlugin, kind: &str); + pub fn selection_feedback(this: &HapticsPlugin); + } +} + +static PLUGIN: OnceLock> = OnceLock::new(); + +fn plugin() -> Result<&'static ffi::HapticsPlugin, &'static str> { + PLUGIN + .get_or_init(ffi::HapticsPlugin::new) + .as_ref() + .map_err(|err| *err) +} + +pub fn vibrate(duration: u32) -> Result<(), &'static str> { + let plugin = plugin()?; + ffi::vibrate(&plugin, i64::from(duration)) +} + +pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), &'static str> { + let plugin = plugin()?; + ffi::impact_feedback(&plugin, &style.to_string()) +} + +pub fn notification_feedback(kind: NotificationFeedbackType) -> Result<(), &'static str> { + let plugin = plugin()?; + ffi::notification_feedback(&plugin, &kind.to_string()) +} + +pub fn selection_feedback() -> Result<(), &'static str> { + let plugin = plugin()?; + ffi::selection_feedback(&plugin) +} diff --git a/packages/haptics/src/lib.rs b/packages/haptics/src/lib.rs new file mode 100644 index 0000000..76d7872 --- /dev/null +++ b/packages/haptics/src/lib.rs @@ -0,0 +1,154 @@ +//! Trigger native haptics feedback on mobile devices. + +use serde::{Deserialize, Serialize}; + +use std::{ + error::Error, + fmt::{self, Display}, +}; + +/// Represents impact feedback intensity. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImpactFeedbackStyle { + /// A light impact. + Light, + /// A medium impact. + #[default] + Medium, + /// A heavy impact. + Heavy, + /// A soft impact. + Soft, + /// A rigid impact. + Rigid, +} + +impl fmt::Display for ImpactFeedbackStyle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + ImpactFeedbackStyle::Light => "light", + ImpactFeedbackStyle::Medium => "medium", + ImpactFeedbackStyle::Heavy => "heavy", + ImpactFeedbackStyle::Soft => "soft", + ImpactFeedbackStyle::Rigid => "rigid", + }; + write!(f, "{s}") + } +} + +/// Represents notification feedback kind. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NotificationFeedbackType { + /// A success notification. + #[default] + Success, + /// A warning notification. + Warning, + /// An error notification. + Error, +} + +impl fmt::Display for NotificationFeedbackType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Success => "success", + Self::Warning => "warning", + Self::Error => "error", + }; + f.write_str(s) + } +} + +/// Represents errors when utilizing the haptics abstraction. +#[derive(Debug)] +pub enum HapticsError { + /// Haptics are unsupported on this platform. + Unsupported, + /// Failure to trigger the vibration. + FailedToVibrate(Box), +} + +impl Error for HapticsError {} +impl Display for HapticsError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Unsupported => write!(f, "haptics are not supported on this platform"), + Self::FailedToVibrate(err) => write!(f, "failed to vibrate: {err}"), + } + } +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +fn failed_to_vibrate(err: impl Display) -> HapticsError { + HapticsError::FailedToVibrate(std::io::Error::other(err.to_string()).into()) +} + +cfg_if::cfg_if! { + if #[cfg(target_os = "android")] { + mod android; + + /// Trigger a simple vibration. + pub fn vibrate(duration: u32) -> Result<(), HapticsError> { + android::vibrate(duration).map_err(failed_to_vibrate) + } + + /// Trigger impact feedback. + pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), HapticsError> { + android::impact_feedback(style).map_err(failed_to_vibrate) + } + + /// Trigger notification feedback. + pub fn notification_feedback(style: NotificationFeedbackType) -> Result<(), HapticsError> { + android::notification_feedback(style).map_err(failed_to_vibrate) + } + + /// Trigger selection feedback. + pub fn selection_feedback() -> Result<(), HapticsError> { + android::selection_feedback().map_err(failed_to_vibrate) + } + } else if #[cfg(target_os = "ios")] { + mod ios; + + /// Trigger a simple vibration. + pub fn vibrate(duration: u32) -> Result<(), HapticsError> { + ios::vibrate(duration).map_err(failed_to_vibrate) + } + + /// Trigger impact feedback. + pub fn impact_feedback(style: ImpactFeedbackStyle) -> Result<(), HapticsError> { + ios::impact_feedback(style).map_err(failed_to_vibrate) + } + + /// Trigger notification feedback. + pub fn notification_feedback(style: NotificationFeedbackType) -> Result<(), HapticsError> { + ios::notification_feedback(style).map_err(failed_to_vibrate) + } + + /// Trigger selection feedback. + pub fn selection_feedback() -> Result<(), HapticsError> { + ios::selection_feedback().map_err(failed_to_vibrate) + } + } else { + /// Trigger a simple vibration. + pub fn vibrate(_duration: u32) -> Result<(), HapticsError> { + Err(HapticsError::Unsupported) + } + + /// Trigger impact feedback. + pub fn impact_feedback(_style: ImpactFeedbackStyle) -> Result<(), HapticsError> { + Err(HapticsError::Unsupported) + } + + /// Trigger notification feedback. + pub fn notification_feedback(_style: NotificationFeedbackType) -> Result<(), HapticsError> { + Err(HapticsError::Unsupported) + } + + /// Trigger selection feedback. + pub fn selection_feedback() -> Result<(), HapticsError> { + Err(HapticsError::Unsupported) + } + } +} diff --git a/packages/sdk/Cargo.toml b/packages/sdk/Cargo.toml index e029a87..942cffb 100644 --- a/packages/sdk/Cargo.toml +++ b/packages/sdk/Cargo.toml @@ -21,6 +21,7 @@ dioxus-sdk-sync = { workspace = true, optional = true } dioxus-sdk-time = { workspace = true, optional = true } dioxus-sdk-util = { workspace = true, optional = true } dioxus-sdk-window = { workspace = true, optional = true } +dioxus-sdk-haptics = { workspace = true, optional = true } [features] geolocation = ["dep:dioxus-sdk-geolocation"] @@ -30,6 +31,7 @@ sync = ["dep:dioxus-sdk-sync"] time = ["dep:dioxus-sdk-time"] util = ["dep:dioxus-sdk-util"] window = ["dep:dioxus-sdk-window"] +haptics = ["dep:dioxus-sdk-haptics"] [package.metadata.docs.rs] all-features = true diff --git a/packages/sdk/src/lib.rs b/packages/sdk/src/lib.rs index 4afa8eb..79eabc3 100644 --- a/packages/sdk/src/lib.rs +++ b/packages/sdk/src/lib.rs @@ -20,6 +20,7 @@ //! | [`dioxus-sdk-notification`] | Send notifications. | `notification` | //! | [`dioxus-sdk-sync`] | Synchronization primities for Dioxus. | `sync` | //! | [`dioxus-sdk-util`] | Misc utilities for Dioxus. | `util` | +//! | [`dioxus-sdk-haptics`] | Haptics utilities for Dioxus. | `haptics` | //! //! [`dioxus-sdk-geolocation`]: https://crates.io/crates/dioxus-sdk-geolocation //! [`dioxus-sdk-storage`]: https://crates.io/crates/dioxus-sdk-storage @@ -28,6 +29,7 @@ //! [`dioxus-sdk-notification`]: https://crates.io/crates/dioxus-sdk-notification //! [`dioxus-sdk-sync`]: https://crates.io/crates/dioxus-sdk-sync //! [`dioxus-sdk-util`]: https://crates.io/crates/dioxus-sdk-util +//! [`dioxus-sdk-haptics`]: https://crates.io/crates/dioxus-sdk-haptics #[cfg(feature = "geolocation")] pub use dioxus_sdk_geolocation as geolocation; @@ -49,3 +51,6 @@ pub use dioxus_sdk_util as util; #[cfg(feature = "window")] pub use dioxus_sdk_window as window; + +#[cfg(feature = "haptics")] +pub use dioxus_sdk_haptics as haptics;