Updated on 2026-08-14

This commit is contained in:
Tangem 2024-08-07 15:42:58 +03:00
parent ac8436460c
commit 459db3d0df
17 changed files with 263 additions and 102 deletions

View file

@ -1,36 +0,0 @@
package com.tangem.tap.common.haptic
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import com.tangem.core.ui.haptic.HapticManager
class DefaultHapticManager(private val vibrator: Vibrator) : HapticManager {
override fun vibrateShort() {
vibrate(VIBRATION_SHORT_DURATION)
}
override fun vibrateMeduim() {
vibrate(VIBRATION_MEDIUM_LOW_DURATION)
}
override fun vibrateLong() {
vibrate(VIBRATION_MEDIUM_DURATION)
}
private fun vibrate(durationMs: Long) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
vibrator.vibrate(durationMs)
}
}
companion object {
const val VIBRATION_SHORT_DURATION = 50L
const val VIBRATION_MEDIUM_LOW_DURATION = 75L
const val VIBRATION_MEDIUM_DURATION = 100L
const val VIBRATION_LONG_DURATION = 200L
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.tap.common.haptic
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import androidx.annotation.ChecksSdkIntAtLeast
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.haptic.VibratorHapticManager
internal class DefaultVibratorHapticManager(
private val vibrator: Vibrator,
) : VibratorHapticManager {
private val isHapticEnabled = deviceSupportsVibrationEffects()
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q)
private fun deviceSupportsVibrationEffects(): Boolean = when {
!vibrator.hasVibrator() -> false
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
vibrator.areAllEffectsSupported(
TangemHapticEffect.OneTime.DoubleClick.code,
TangemHapticEffect.OneTime.HeavyClick.code,
TangemHapticEffect.OneTime.Tick.code,
TangemHapticEffect.OneTime.Click.code,
) == Vibrator.VIBRATION_EFFECT_SUPPORT_YES
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q -> true
else -> false
}
override fun performOneTime(effect: TangemHapticEffect.OneTime) {
if (!isHapticEnabled) return
vibrator.vibrate(VibrationEffect.createPredefined(effect.code))
}
}

View file

@ -4,9 +4,9 @@ import android.content.Context
import android.os.Build
import android.os.Vibrator
import android.os.VibratorManager
import com.tangem.tap.common.haptic.DefaultHapticManager
import com.tangem.core.ui.haptic.HapticManager
import com.tangem.core.ui.haptic.MockHapticManager
import com.tangem.tap.common.haptic.DefaultVibratorHapticManager
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.haptic.VibratorHapticManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -20,7 +20,7 @@ class HapticModule {
@Provides
@Singleton
fun provideHapticManager(@ApplicationContext context: Context): HapticManager {
fun provideHapticManager(@ApplicationContext context: Context): VibratorHapticManager {
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
@ -29,9 +29,12 @@ class HapticModule {
}
return if (vibrator.hasVibrator()) {
DefaultHapticManager(vibrator = vibrator)
DefaultVibratorHapticManager(vibrator = vibrator)
} else {
MockHapticManager
// mock
object : VibratorHapticManager {
override fun performOneTime(effect: TangemHapticEffect.OneTime) = Unit
}
}
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.tap.di
import androidx.compose.material3.SnackbarHostState
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.haptic.HapticManager
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.core.ui.message.EventMessageHandler
import com.tangem.core.ui.theme.AppThemeModeHolder
import dagger.Module
@ -17,9 +17,12 @@ internal object UiDependenciesModule {
@Provides
@Singleton
fun provideUiDependencies(hapticManager: HapticManager, appThemeModeHolder: AppThemeModeHolder): UiDependencies {
fun provideUiDependencies(
vibratorHapticManager: VibratorHapticManager,
appThemeModeHolder: AppThemeModeHolder,
): UiDependencies {
return object : UiDependencies {
override val hapticManager = hapticManager
override val vibratorHapticManager = vibratorHapticManager
override val appThemeModeHolder = appThemeModeHolder
override val globalSnackbarHostState: SnackbarHostState = SnackbarHostState()
override val eventMessageHandler: EventMessageHandler = EventMessageHandler()

View file

@ -11,11 +11,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontSynthesis
import androidx.compose.ui.text.font.FontWeight
@ -52,6 +50,8 @@ import com.tangem.common.ui.charts.layer.rememberTangemChartMarker
import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider
import com.tangem.common.ui.charts.state.*
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.DateTimeFormatters
@ -134,7 +134,8 @@ private fun rememberMarketVisibilityListener(
canvasWidth: Int,
state: MarketChartState,
): CartesianMarkerVisibilityListener {
val haptic = LocalHapticFeedback.current
val haptic = LocalHapticManager.current
return remember(state, canvasWidth) {
val maxCanvasXFloat = canvasWidth.toFloat().takeIf { it != 0f }
@ -145,7 +146,7 @@ private fun rememberMarketVisibilityListener(
state.markerFraction = maxCanvasXFloat?.let { xCanvas / it }
state.markerVisibilityListener.onShown(marker, targets)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
haptic.perform(TangemHapticEffect.View.ContextClick)
}
override fun onHidden(marker: CartesianMarker) {
@ -158,7 +159,8 @@ private fun rememberMarketVisibilityListener(
state.markerFraction = maxCanvasXFloat?.let { xCanvas / it }
state.markerVisibilityListener.onUpdated(marker, targets)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
haptic.perform(TangemHapticEffect.View.TextHandleMove)
}
}
}

View file

@ -2,14 +2,14 @@ package com.tangem.core.ui
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Stable
import com.tangem.core.ui.haptic.HapticManager
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.core.ui.message.EventMessageHandler
import com.tangem.core.ui.theme.AppThemeModeHolder
@Stable
interface UiDependencies {
val hapticManager: HapticManager
val vibratorHapticManager: VibratorHapticManager
val appThemeModeHolder: AppThemeModeHolder

View file

@ -0,0 +1,32 @@
package com.tangem.core.ui.haptic
import android.view.View
import androidx.core.view.ViewCompat
internal class DefaultHapticManager(
private val view: View,
private val vibratorHapticManager: VibratorHapticManager?,
) : HapticManager {
override fun perform(effect: TangemHapticEffect) {
when (effect) {
is TangemHapticEffect.View -> {
effect.androidHapticFeedbackCode?.let {
ViewCompat.performHapticFeedback(view, it)
}
}
is TangemHapticEffect.OneTime -> {
if (vibratorHapticManager != null) {
vibratorHapticManager.performOneTime(effect)
} else {
when (effect) {
TangemHapticEffect.OneTime.Tick -> perform(TangemHapticEffect.View.SegmentTick)
TangemHapticEffect.OneTime.Click -> perform(TangemHapticEffect.View.ContextClick)
TangemHapticEffect.OneTime.DoubleClick -> perform(TangemHapticEffect.View.ContextClick)
TangemHapticEffect.OneTime.HeavyClick -> perform(TangemHapticEffect.View.LongPress)
}
}
}
}
}
}

View file

@ -2,12 +2,13 @@ package com.tangem.core.ui.haptic
import androidx.compose.runtime.Stable
/**
* Haptic feedback.
* @see [TangemHapticEffect.OneTime] for one-time effects.
* @see [TangemHapticEffect.View] for view effects.
*/
@Stable
interface HapticManager {
fun vibrateShort()
fun vibrateMeduim()
fun vibrateLong()
fun perform(effect: TangemHapticEffect)
}

View file

@ -1,30 +0,0 @@
package com.tangem.core.ui.haptic
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@Suppress("FunctionName")
fun MockHapticManager(mockHapticFeedback: HapticFeedback? = null): HapticManager =
if (mockHapticFeedback == null) MockHapticManager else MockHapticManagerImpl(mockHapticFeedback)
val MockHapticManager: HapticManager = MockHapticManagerImpl()
private class MockHapticManagerImpl(
private val mockHapticFeedback: HapticFeedback? = null,
) : HapticManager {
override fun vibrateShort() {
mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
/** Intentionally do nothing */
}
override fun vibrateMeduim() {
mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
/** Intentionally do nothing */
}
override fun vibrateLong() {
mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.LongPress)
/** Intentionally do nothing */
}
}

View file

@ -0,0 +1,125 @@
package com.tangem.core.ui.haptic
import android.os.Build
import android.os.VibrationEffect
import androidx.annotation.RequiresApi
sealed interface TangemHapticEffect {
/**
* For cases when view could not be visible on screen (ex. Activity is not in foreground)
* or there is no view context (ex. background service, Model, ViewModel)
*/
enum class OneTime : TangemHapticEffect {
Click,
DoubleClick,
HeavyClick,
Tick,
;
val code: Int
@RequiresApi(Build.VERSION_CODES.Q)
get() = when (this) {
Click -> VibrationEffect.EFFECT_CLICK
DoubleClick -> VibrationEffect.EFFECT_DOUBLE_CLICK
HeavyClick -> VibrationEffect.EFFECT_HEAVY_CLICK
Tick -> VibrationEffect.EFFECT_TICK
}
}
/**
* Preferred way to provide haptic feedback for UI components
* @see [androidx.core.view.HapticFeedbackConstantsCompat]
*/
@Suppress("MagicNumber")
enum class View(internal val androidHapticFeedbackCode: Int? = null) : TangemHapticEffect {
/**
* The user has performed a long press on an object that is resulting in an action being
* performed
*/
LongPress(0),
/**
* The user has pressed on a virtual on-screen key
*/
VirtualKey(1),
/**
* The user has pressed either an hour or minute tick of a Clock
*/
ClockTick(4),
/**
* The user has performed a context click on an object
*/
ContextClick(6),
/**
* The user has pressed a virtual or software keyboard key
*/
KeyboardPress(3),
/**
* The user has released a virtual keyboard key
*/
KeyboardRelease(7),
/**
* The user has released a virtual key
*/
VirtualKeyRelease(8),
/**
* The user has performed a selection/insertion handle move on text field
*/
TextHandleMove(9),
/**
* The user has started a gesture (e.g. on the soft keyboard)
*/
GestureStart(12),
/**
* The user has finished a gesture (e.g. on the soft keyboard)
*/
GestureEnd(13),
/**
* A haptic effect to signal the confirmation or successful completion of a user interaction
*/
Confirm(16),
/**
* A haptic effect to signal the rejection or failure of a user interaction
*/
Reject(17),
/**
* The user has toggled a switch or button into the on position
*/
ToggleOn(21),
/**
* The user has toggled a switch or button into the off position
*/
ToggleOff(22),
/**
* The user is executing a swipe/drag-style gesture, such as pull-to-refresh, where the
* gesture action is eligible at a certain threshold of movement, and can be cancelled by
* moving back past the threshold. This constant indicates that the user's motion has just
* passed the threshold for the action to be activated on release
*/
GestureThresholdActivate(23),
/**
* The user is executing a swipe/drag-style gesture, such as pull-to-refresh, where the
* gesture action is eligible at a certain threshold of movement, and can be cancelled by
* moving back past the threshold. This constant indicates that the user's motion has just
* re-crossed back "under" the threshold for the action to be activated, meaning the gesture is
* currently in a cancelled state
*/
GestureThresholdDeactivate(24),
/**
* The user has started a drag-and-drop gesture. The drag target has just been "picked up"
*/
DragStart(25),
/**
* The user is switching between a series of potential choices, for example items in a list
* or discrete points on a slider
*/
SegmentTick(26),
/**
* The user is switching between a series of many potential choices, for example minutes on a
* clock face, or individual percentages. This constant is expected to be very soft, so as
* not to be uncomfortable when performed a lot in quick succession. If the device cant make
* a suitably soft vibration, then it may not make any vibration
*/
SegmentFrequentTick(27),
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.core.ui.haptic
import androidx.compose.runtime.Stable
/**
* Haptic feedback
* For cases when view could not be visible on screen (ex. Activity is not in foreground)
* or there is no view context (ex. background service, Model, ViewModel)
* @see [HapticManager] for view effects.
*/
@Stable
interface VibratorHapticManager {
fun performOneTime(effect: TangemHapticEffect.OneTime)
}

View file

@ -8,10 +8,12 @@ import androidx.compose.material.ProvideTextStyle
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.tangem.core.ui.components.TangemShimmer
import com.tangem.core.ui.haptic.DefaultHapticManager
import com.tangem.core.ui.haptic.HapticManager
import com.tangem.core.ui.haptic.MockHapticManager
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.core.ui.windowsize.WindowSize
import com.valentinilk.shimmer.Shimmer
@ -21,7 +23,7 @@ fun TangemTheme(
windowSize: WindowSize,
typography: TangemTypography = TangemTheme.typography,
dimens: TangemDimens = TangemTheme.dimens,
hapticManager: HapticManager = MockHapticManager,
vibratorHapticManager: VibratorHapticManager? = null,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable () -> Unit,
) {
@ -40,6 +42,12 @@ fun TangemTheme(
)
}
val view = LocalView.current
val hapticManager = remember(view) {
DefaultHapticManager(view = view, vibratorHapticManager = vibratorHapticManager)
}
MaterialTheme(
colors = materialThemeColors(colors = themeColors, isDark = isDark),
) {

View file

@ -6,8 +6,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalHapticFeedback
import com.tangem.core.ui.haptic.MockHapticManager
import com.tangem.core.ui.windowsize.rememberWindowSizePreview
@Composable
@ -29,7 +27,6 @@ fun TangemThemePreview(
typography = typography,
dimens = dimens,
windowSize = rememberWindowSizePreview(maxWidth, maxHeight),
hapticManager = MockHapticManager(LocalHapticFeedback.current),
content = content,
)
}

View file

@ -61,7 +61,7 @@ internal fun ComposeScreen.createComposeView(context: Context, activity: Activit
TangemTheme(
isDark = shouldUseDarkTheme(appThemeMode),
windowSize = windowSize,
hapticManager = uiDependencies.hapticManager,
vibratorHapticManager = uiDependencies.vibratorHapticManager,
snackbarHostState = uiDependencies.globalSnackbarHostState,
) {
ScreenContent(modifier = screenModifier)

View file

@ -39,6 +39,7 @@ import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
@ -97,14 +98,14 @@ fun MarketsListItem(
.collect {
val border = -actionWidthPx * SWIPE_THRESHOLD_PERCENT
if (it < border && actionPerformed.not()) {
hapticManager.vibrateLong()
hapticManager.perform(TangemHapticEffect.View.GestureThresholdActivate)
actionPerformed = true
releasePerformed = false
}
if (it > border) {
if (releasePerformed.not()) {
hapticManager.vibrateShort()
hapticManager.perform(TangemHapticEffect.View.GestureThresholdDeactivate)
releasePerformed = true
}
actionPerformed = false

View file

@ -19,7 +19,8 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.haptic.HapticManager
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
@ -120,7 +121,7 @@ internal class TokenDetailsViewModel @Inject constructor(
private val associateAssetUseCase: AssociateAssetUseCase,
private val reduxStateHolder: ReduxStateHolder,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val hapticManager: HapticManager,
private val vibratorHapticManager: VibratorHapticManager,
private val clipboardManager: ClipboardManager,
private val getUserWalletUseCase: GetUserWalletUseCase,
tokenDetailsFeatureToggles: TokenDetailsFeatureToggles,
@ -580,7 +581,7 @@ internal class TokenDetailsViewModel @Inject constructor(
ifRight = { it },
)
if (extendedKey.isNotBlank()) {
hapticManager.vibrateMeduim()
vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click)
clipboardManager.setText(text = extendedKey)
internalUiState.value = stateFactory.getStateAndTriggerEvent(
state = internalUiState.value,
@ -802,7 +803,7 @@ internal class TokenDetailsViewModel @Inject constructor(
val addresses = networkAddress.availableAddresses.mapToAddressModels(cryptoCurrency).toImmutableList()
val defaultAddress = addresses.firstOrNull()?.value ?: return null
hapticManager.vibrateMeduim()
vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click)
clipboardManager.setText(text = defaultAddress)
analyticsEventsHandler.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(cryptoCurrency.symbol))
return resourceReference(R.string.wallet_notification_address_copied)

View file

@ -13,7 +13,8 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.haptic.HapticManager
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.extenstions.unwrap
import com.tangem.domain.common.util.cardTypesResolver
@ -91,7 +92,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val dispatchers: CoroutineDispatcherProvider,
private val reduxStateHolder: ReduxStateHolder,
private val hapticManager: HapticManager,
private val vibratorHapticManager: VibratorHapticManager,
private val clipboardManager: ClipboardManager,
private val getYieldUseCase: GetYieldUseCase,
) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents {
@ -192,7 +193,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
val addresses = networkAddress.availableAddresses.mapToAddressModels(cryptoCurrency).toImmutableList()
val defaultAddress = addresses.firstOrNull()?.value ?: return null
hapticManager.vibrateMeduim()
vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click)
clipboardManager.setText(text = defaultAddress)
analyticsEventHandler.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(cryptoCurrency.symbol))
return resourceReference(R.string.wallet_notification_address_copied)