diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 01baac26a8..4f1e643f0c 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -372,6 +372,12 @@ class SettingsRepository( const val KEY_DUO_HIDE_WHEN_SCREEN_OFF = "duo_hide_when_screen_off" const val KEY_DUO_HIDE_WHEN_SCREEN_OFF_ONLY_IDLE = "duo_hide_when_screen_off_only_idle" const val KEY_DUO_USE_MATERIAL_YOU = "duo_use_material_you" + const val KEY_DUO_ENABLE_GESTURES = "duo_enable_gestures" + const val KEY_DUO_GESTURE_HAPTIC = "duo_gesture_haptic" + const val KEY_DUO_GESTURE_SINGLE_TAP = "duo_gesture_single_tap" + const val KEY_DUO_GESTURE_DOUBLE_TAP = "duo_gesture_double_tap" + const val KEY_DUO_GESTURE_ANIMATIONS = "duo_gesture_animations" + const val KEY_DUO_ROTARY_DIAL = "duo_rotary_dial" // Live Wallpaper const val LIVE_WALLPAPER_PREFS_NAME = "live_wallpaper_prefs" @@ -3117,4 +3123,22 @@ class SettingsRepository( fun isDuoUseMaterialYouEnabled(): Boolean = getBoolean(KEY_DUO_USE_MATERIAL_YOU, true) fun setDuoUseMaterialYouEnabled(enabled: Boolean) = putBoolean(KEY_DUO_USE_MATERIAL_YOU, enabled) + + fun isDuoEnableGesturesEnabled(): Boolean = getBoolean(KEY_DUO_ENABLE_GESTURES, true) + fun setDuoEnableGesturesEnabled(enabled: Boolean) = putBoolean(KEY_DUO_ENABLE_GESTURES, enabled) + + fun isDuoGestureHapticEnabled(): Boolean = getBoolean(KEY_DUO_GESTURE_HAPTIC, true) + fun setDuoGestureHapticEnabled(enabled: Boolean) = putBoolean(KEY_DUO_GESTURE_HAPTIC, enabled) + + fun getDuoGestureSingleTapAction(): String = getString(KEY_DUO_GESTURE_SINGLE_TAP, "notifications") ?: "notifications" + fun setDuoGestureSingleTapAction(action: String) = putString(KEY_DUO_GESTURE_SINGLE_TAP, action) + + fun getDuoGestureDoubleTapAction(): String = getString(KEY_DUO_GESTURE_DOUBLE_TAP, "lock_screen") ?: "lock_screen" + fun setDuoGestureDoubleTapAction(action: String) = putString(KEY_DUO_GESTURE_DOUBLE_TAP, action) + + fun isDuoGestureAnimationsEnabled(): Boolean = getBoolean(KEY_DUO_GESTURE_ANIMATIONS, true) + fun setDuoGestureAnimationsEnabled(enabled: Boolean) = putBoolean(KEY_DUO_GESTURE_ANIMATIONS, enabled) + + fun isDuoRotaryDialEnabled(): Boolean = getBoolean(KEY_DUO_ROTARY_DIAL, true) + fun setDuoRotaryDialEnabled(enabled: Boolean) = putBoolean(KEY_DUO_ROTARY_DIAL, enabled) } diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt index 4e24546c39..95ff2c14f6 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt @@ -22,6 +22,7 @@ import android.graphics.Canvas import android.graphics.Color import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraManager +import android.media.AudioManager import android.media.MediaMetadata import android.media.session.MediaController import android.media.session.MediaSession @@ -42,7 +43,9 @@ import android.telephony.SignalStrength import android.telephony.TelephonyCallback import android.telephony.TelephonyManager import android.util.DisplayMetrics +import android.graphics.PixelFormat import android.util.Log +import android.view.Gravity import android.view.WindowManager import androidx.core.content.ContextCompat import com.google.gson.Gson @@ -53,9 +56,12 @@ import com.sameerasw.essentials.domain.model.AppSelection import com.sameerasw.essentials.domain.model.ProgressNotificationData import com.sameerasw.essentials.services.NotificationListener import com.sameerasw.essentials.utils.AppUtil +import com.sameerasw.essentials.utils.DuoFeedbackType import com.sameerasw.essentials.utils.DuoOverlayView +import com.sameerasw.essentials.utils.DuoTouchAnchorView import com.sameerasw.essentials.utils.FlashlightUtil import com.sameerasw.essentials.utils.OverlayHelper +import kotlin.math.abs import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -69,6 +75,8 @@ class DuoOverlayHandler( private var windowManager: WindowManager? = null private var overlayView: DuoOverlayView? = null private var isOverlayAdded = false + private var touchAnchorView: DuoTouchAnchorView? = null + private var isTouchAnchorAdded = false private val mainHandler = Handler(Looper.getMainLooper()) private val settingsRepository by lazy { SettingsRepository(service) } @@ -76,9 +84,11 @@ class DuoOverlayHandler( private val connectivityManager by lazy { service.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager } private val wifiManager by lazy { service.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager } private val cameraManager by lazy { service.getSystemService(Context.CAMERA_SERVICE) as CameraManager } + private val audioManager by lazy { service.getSystemService(Context.AUDIO_SERVICE) as? AudioManager } private var isFlashlightOn = false private var currentFlashlightLevel = 1 + private var rotaryAngleDegreesAccumulator: Float = 0f private var flashlightIconBitmap: Bitmap? = null private var isTorchCallbackRegistered = false @@ -187,6 +197,11 @@ class DuoOverlayHandler( if (isFullscreen != fullscreen) { isFullscreen = fullscreen overlayView?.isFullscreen = fullscreen + if (fullscreen) { + removeTouchAnchor() + } else { + updateState() + } } } @@ -204,6 +219,7 @@ class DuoOverlayHandler( fun onScreenOff() { isScreenOff = true overlayView?.isScreenOff = true + removeTouchAnchor() updateState() } @@ -671,6 +687,58 @@ class DuoOverlayHandler( overlayView?.invalidate() } + val gesturesEnabled = settingsRepository.isDuoEnableGesturesEnabled() + val canShowTouchAnchor = gesturesEnabled && !this@DuoOverlayHandler.isFullscreen && !this@DuoOverlayHandler.isScreenOff + + if (canShowTouchAnchor) { + val touchPaddingPx = if (settingsRepository.isDuoRotaryDialEnabled()) 26f * density else 16f * density + val touchRadius = cameraRadiusPx + touchPaddingPx + val touchDiameter = (touchRadius * 2f).toInt() + + if (touchAnchorView == null) { + touchAnchorView = DuoTouchAnchorView(service).apply { + setupTouchCallbacks(this) + } + } + + touchAnchorView?.isHapticEnabled = settingsRepository.isDuoGestureHapticEnabled() + touchAnchorView?.isRotaryEnabled = settingsRepository.isDuoRotaryDialEnabled() + + val anchorParams = WindowManager.LayoutParams( + touchDiameter, + touchDiameter, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT + ).apply { + gravity = Gravity.TOP or Gravity.START + x = (centerX - touchRadius).toInt() + y = (centerY - touchRadius).toInt() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + } + + if (!isTouchAnchorAdded) { + try { + wm.addView(touchAnchorView, anchorParams) + isTouchAnchorAdded = true + } catch (e: Exception) { + Log.e("DuoOverlayHandler", "Failed to add Duo touch anchor", e) + } + } else { + try { + wm.updateViewLayout(touchAnchorView, anchorParams) + } catch (_: Exception) {} + } + } else { + removeTouchAnchor() + } + registerBatteryReceiver() registerSignalListeners() updateCurrentSignal() @@ -922,8 +990,255 @@ class DuoOverlayHandler( } } + private fun setupTouchCallbacks(anchor: DuoTouchAnchorView) { + anchor.onTouchDown = { + overlayView?.triggerTouchBounce() + } + anchor.onSingleTap = { + handleCutoutSingleTap() + } + anchor.onDoubleTap = { + handleCutoutDoubleTap() + } + anchor.onLongPress = { + handleCutoutLongPress() + } + anchor.onSwipeRight = { + handleCutoutSwipe(isRight = true) + } + anchor.onSwipeLeft = { + handleCutoutSwipe(isRight = false) + } + anchor.onLongPressProgress = { fraction -> + if (settingsRepository.isDuoGestureAnimationsEnabled()) { + overlayView?.setChargeProgress(fraction) + } + } + anchor.onRotaryDelta = { deltaAngle -> + handleCutoutRotary(deltaAngle) + } + anchor.onRotaryEnd = { + rotaryAngleDegreesAccumulator = 0f + } + } + + private fun handleCutoutSingleTap() { + if (isFlashlightOn) { + turnOffFlashlight() + if (settingsRepository.isDuoGestureAnimationsEnabled()) { + overlayView?.triggerActionFeedback(DuoFeedbackType.TORCH_OFF) + } + return + } + val controller = activeMediaController + if (isMediaPlaying && controller != null) { + val state = controller.playbackState?.state + if (state == PlaybackState.STATE_PLAYING) { + controller.transportControls.pause() + if (settingsRepository.isDuoGestureAnimationsEnabled()) { + overlayView?.triggerActionFeedback(DuoFeedbackType.MEDIA_PAUSE) + } + } else { + controller.transportControls.play() + if (settingsRepository.isDuoGestureAnimationsEnabled()) { + overlayView?.triggerActionFeedback(DuoFeedbackType.MEDIA_PLAY) + } + } + return + } + executeConfiguredAction(settingsRepository.getDuoGestureSingleTapAction()) + } + + private fun handleCutoutDoubleTap() { + val controller = activeMediaController + if (isMediaPlaying && controller != null) { + controller.transportControls.skipToNext() + if (settingsRepository.isDuoGestureAnimationsEnabled()) { + overlayView?.triggerActionFeedback(DuoFeedbackType.TRACK_NEXT) + } + return + } + executeConfiguredAction(settingsRepository.getDuoGestureDoubleTapAction()) + } + + private fun handleCutoutLongPress() { + if (settingsRepository.isDuoGestureAnimationsEnabled()) { + overlayView?.setChargeProgress(0f) + } + val controller = activeMediaController + if (isMediaPlaying && controller != null) { + val sessionActivity = controller.sessionActivity + if (sessionActivity != null) { + try { + sessionActivity.send() + return + } catch (_: Exception) {} + } + launchAppPackage(controller.packageName) + } else { + toggleFlashlight() + if (settingsRepository.isDuoGestureAnimationsEnabled()) { + val feedback = if (isFlashlightOn) DuoFeedbackType.TORCH_OFF else DuoFeedbackType.TORCH_ON + overlayView?.triggerActionFeedback(feedback) + } + } + } + + private fun handleCutoutSwipe(isRight: Boolean) { + val controller = activeMediaController ?: return + if (isRight) { + controller.transportControls.skipToNext() + if (settingsRepository.isDuoGestureAnimationsEnabled()) { + overlayView?.triggerActionFeedback(DuoFeedbackType.TRACK_NEXT) + } + } else { + controller.transportControls.skipToPrevious() + if (settingsRepository.isDuoGestureAnimationsEnabled()) { + overlayView?.triggerActionFeedback(DuoFeedbackType.TRACK_PREV) + } + } + } + + private fun executeConfiguredAction(action: String) { + if (settingsRepository.isDuoGestureAnimationsEnabled()) { + val feedback = when (action) { + "notifications" -> DuoFeedbackType.NOTIFICATIONS + "quick_settings" -> DuoFeedbackType.QUICK_SETTINGS + "lock_screen" -> DuoFeedbackType.LOCK_SCREEN + "screenshot" -> DuoFeedbackType.SCREENSHOT + "recents" -> DuoFeedbackType.RECENTS + "torch" -> if (isFlashlightOn) DuoFeedbackType.TORCH_OFF else DuoFeedbackType.TORCH_ON + else -> DuoFeedbackType.NONE + } + if (feedback != DuoFeedbackType.NONE) { + overlayView?.triggerActionFeedback(feedback) + } + } + + when (action) { + "notifications" -> { + service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS) + } + "quick_settings" -> { + service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_QUICK_SETTINGS) + } + "lock_screen" -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_LOCK_SCREEN) + } + } + "screenshot" -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_TAKE_SCREENSHOT) + } + } + "recents" -> { + service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_RECENTS) + } + "torch" -> { + toggleFlashlight() + } + else -> { + // "none" or unhandled + } + } + } + + private fun handleCutoutRotary(deltaAngle: Float) { + if (!settingsRepository.isDuoRotaryDialEnabled()) return + + if (isFlashlightOn && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val id = getCameraId() ?: return + try { + val characteristics = cameraManager.getCameraCharacteristics(id) + val maxLevel = characteristics.get(CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL) ?: 1 + if (maxLevel > 1) { + rotaryAngleDegreesAccumulator += deltaAngle + val stepDeg = 14f + if (abs(rotaryAngleDegreesAccumulator) >= stepDeg) { + val steps = (rotaryAngleDegreesAccumulator / stepDeg).toInt() + rotaryAngleDegreesAccumulator %= stepDeg + val newLevel = (currentFlashlightLevel + steps).coerceIn(1, maxLevel) + if (newLevel != currentFlashlightLevel) { + currentFlashlightLevel = newLevel + cameraManager.turnOnTorchWithStrengthLevel(id, newLevel) + val ratio = newLevel.toFloat() / maxLevel.toFloat() + overlayView?.showRotaryLevel(ratio, isVolume = false) + } + } + return + } + } catch (_: Exception) {} + } + + val am = audioManager ?: return + val maxVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val currentVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC) + rotaryAngleDegreesAccumulator += deltaAngle + val stepDeg = 12f + if (abs(rotaryAngleDegreesAccumulator) >= stepDeg) { + val steps = (rotaryAngleDegreesAccumulator / stepDeg).toInt() + rotaryAngleDegreesAccumulator %= stepDeg + val targetVolume = (currentVolume + steps).coerceIn(0, maxVolume) + if (targetVolume != currentVolume) { + am.setStreamVolume(AudioManager.STREAM_MUSIC, targetVolume, 0) + } + val ratio = if (maxVolume > 0) targetVolume.toFloat() / maxVolume.toFloat() else 0f + overlayView?.showRotaryLevel(ratio, isVolume = true) + } + } + + private fun toggleFlashlight() { + val id = getCameraId() ?: return + try { + cameraManager.setTorchMode(id, !isFlashlightOn) + } catch (e: Exception) { + Log.e("DuoOverlayHandler", "Failed to toggle flashlight", e) + } + } + + private fun turnOffFlashlight() { + if (!isFlashlightOn) return + val id = getCameraId() ?: return + try { + cameraManager.setTorchMode(id, false) + } catch (e: Exception) { + Log.e("DuoOverlayHandler", "Failed to turn off flashlight", e) + } + } + + private fun launchAppPackage(packageName: String?) { + if (packageName.isNullOrBlank()) return + try { + val launchIntent = service.packageManager.getLaunchIntentForPackage(packageName) + if (launchIntent != null) { + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + service.startActivity(launchIntent) + } + } catch (e: Exception) { + Log.e("DuoOverlayHandler", "Failed to launch package $packageName", e) + } + } + + private fun removeTouchAnchor() { + val runnable = Runnable { + if (isTouchAnchorAdded && touchAnchorView != null) { + try { + windowManager?.removeView(touchAnchorView) + } catch (_: Exception) {} + isTouchAnchorAdded = false + } + } + if (Looper.myLooper() == Looper.getMainLooper()) { + runnable.run() + } else { + mainHandler.post(runnable) + } + } + fun removeOverlay() { mainHandler.post { + removeTouchAnchor() if (isOverlayAdded && overlayView != null) { try { windowManager?.removeView(overlayView) @@ -941,6 +1256,7 @@ class DuoOverlayHandler( fun destroy() { removeOverlay() overlayView = null + touchAnchorView = null currentArtOrIconBitmap = null currentMediaKey = null flashlightIconBitmap = null diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index 8114767735..094a7f7403 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -260,7 +260,13 @@ class ScreenOffAccessibilityService : key == SettingsRepository.KEY_DUO_SHOW_FLASHLIGHT || key == SettingsRepository.KEY_DUO_HIDE_WHEN_SCREEN_OFF || key == SettingsRepository.KEY_DUO_HIDE_WHEN_SCREEN_OFF_ONLY_IDLE || - key == SettingsRepository.KEY_DUO_USE_MATERIAL_YOU + key == SettingsRepository.KEY_DUO_USE_MATERIAL_YOU || + key == SettingsRepository.KEY_DUO_ENABLE_GESTURES || + key == SettingsRepository.KEY_DUO_GESTURE_HAPTIC || + key == SettingsRepository.KEY_DUO_GESTURE_ANIMATIONS || + key == SettingsRepository.KEY_DUO_ROTARY_DIAL || + key == SettingsRepository.KEY_DUO_GESTURE_SINGLE_TAP || + key == SettingsRepository.KEY_DUO_GESTURE_DOUBLE_TAP ) { duoOverlayHandler.updateState() } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt index 7405f30016..1610289312 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt @@ -33,7 +33,9 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem import com.sameerasw.essentials.ui.components.sliders.ConfigSliderItem +import com.sameerasw.essentials.ui.core.cards.ConfigPickerItem import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.sheets.AppSelectionSheet @@ -296,6 +298,134 @@ fun DuoSettingsUI( ) } + Text( + text = stringResource(R.string.duo_section_gestures), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + + RoundedCardContainer( + spacing = 2.dp, + cornerRadius = 24.dp, + ) { + IconToggleItem( + iconRes = R.drawable.rounded_touch_app_24, + title = stringResource(R.string.duo_enable_gestures_title), + description = stringResource(R.string.duo_enable_gestures_desc), + isChecked = viewModel.isDuoEnableGestures.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setDuoEnableGestures(checked) + }, + modifier = Modifier.highlight(highlightSetting == "duo_enable_gestures"), + ) + + AnimatedVisibility( + visible = viewModel.isDuoEnableGestures.value, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + IconToggleItem( + iconRes = R.drawable.rounded_mobile_vibrate_24, + title = stringResource(R.string.duo_gesture_haptic_title), + description = stringResource(R.string.duo_gesture_haptic_desc), + isChecked = viewModel.isDuoGestureHaptic.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setDuoGestureHaptic(checked) + }, + modifier = Modifier.highlight(highlightSetting == "duo_gesture_haptic"), + ) + + IconToggleItem( + iconRes = R.drawable.rounded_auto_awesome_24, + title = stringResource(R.string.duo_gesture_animations_title), + description = stringResource(R.string.duo_gesture_animations_desc), + isChecked = viewModel.isDuoGestureAnimations.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setDuoGestureAnimations(checked) + }, + modifier = Modifier.highlight(highlightSetting == "duo_gesture_animations"), + ) + + IconToggleItem( + iconRes = R.drawable.rounded_rotate_right_24, + title = stringResource(R.string.duo_rotary_dial_title), + description = stringResource(R.string.duo_rotary_dial_desc), + isChecked = viewModel.isDuoRotaryDial.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setDuoRotaryDial(checked) + }, + modifier = Modifier.highlight(highlightSetting == "duo_rotary_dial"), + ) + + val singleTapOptions = listOf( + "notifications" to stringResource(R.string.duo_action_notifications), + "quick_settings" to stringResource(R.string.duo_action_quick_settings), + "screenshot" to stringResource(R.string.duo_action_screenshot), + "lock_screen" to stringResource(R.string.duo_action_lock_screen), + "torch" to stringResource(R.string.duo_action_torch), + "recents" to stringResource(R.string.duo_action_recents), + "none" to stringResource(R.string.duo_action_none), + ) + val currentSingleTap = viewModel.duoGestureSingleTapAction.value + val singleTapLabel = singleTapOptions.firstOrNull { it.first == currentSingleTap }?.second + ?: stringResource(R.string.duo_action_notifications) + + ConfigPickerItem( + title = stringResource(R.string.duo_gesture_single_tap_title), + description = stringResource(R.string.duo_gesture_single_tap_desc), + selectedValue = singleTapLabel, + iconRes = R.drawable.rounded_touch_app_24, + ) { + singleTapOptions.forEach { (actionKey, label) -> + SegmentedDropdownMenuItem( + text = { Text(label) }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setDuoGestureSingleTapAction(actionKey) + }, + ) + } + } + + val doubleTapOptions = listOf( + "lock_screen" to stringResource(R.string.duo_action_lock_screen), + "recents" to stringResource(R.string.duo_action_recents), + "notifications" to stringResource(R.string.duo_action_notifications), + "quick_settings" to stringResource(R.string.duo_action_quick_settings), + "screenshot" to stringResource(R.string.duo_action_screenshot), + "torch" to stringResource(R.string.duo_action_torch), + "none" to stringResource(R.string.duo_action_none), + ) + val currentDoubleTap = viewModel.duoGestureDoubleTapAction.value + val doubleTapLabel = doubleTapOptions.firstOrNull { it.first == currentDoubleTap }?.second + ?: stringResource(R.string.duo_action_lock_screen) + + ConfigPickerItem( + title = stringResource(R.string.duo_gesture_double_tap_title), + description = stringResource(R.string.duo_gesture_double_tap_desc), + selectedValue = doubleTapLabel, + iconRes = R.drawable.rounded_touch_app_24, + ) { + doubleTapOptions.forEach { (actionKey, label) -> + SegmentedDropdownMenuItem( + text = { Text(label) }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setDuoGestureDoubleTapAction(actionKey) + }, + ) + } + } + } + } + } + Text( text = stringResource(R.string.duo_section_behavior), style = MaterialTheme.typography.titleSmall, diff --git a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt index 803c6253d0..71d08dd8fb 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt @@ -9,6 +9,8 @@ package com.sameerasw.essentials.utils +import android.animation.Animator +import android.animation.AnimatorListenerAdapter import android.animation.ArgbEvaluator import android.animation.ValueAnimator import android.content.Context @@ -22,6 +24,8 @@ import android.graphics.PorterDuffColorFilter import android.graphics.RectF import android.os.Build import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.AccelerateInterpolator import android.view.animation.DecelerateInterpolator import android.view.animation.LinearInterpolator import android.view.animation.OvershootInterpolator @@ -30,6 +34,21 @@ import androidx.palette.graphics.Palette import kotlin.math.cos import kotlin.math.sin +enum class DuoFeedbackType { + NONE, + SCREENSHOT, + LOCK_SCREEN, + NOTIFICATIONS, + QUICK_SETTINGS, + MEDIA_PLAY, + MEDIA_PAUSE, + TRACK_NEXT, + TRACK_PREV, + TORCH_ON, + TORCH_OFF, + RECENTS +} + class DuoOverlayView(context: Context) : View(context) { private val density = resources.displayMetrics.density @@ -220,6 +239,7 @@ class DuoOverlayView(context: Context) : View(context) { var flashlightBrightnessProgress: Float = 100f private set + private var touchBounceAnimator: ValueAnimator? = null private var mediaPaletteColors: Pair? = null private fun extractMediaColors(bitmap: Bitmap): Pair { @@ -398,6 +418,136 @@ class DuoOverlayView(context: Context) : View(context) { } } + fun triggerTouchBounce() { + touchBounceAnimator?.cancel() + touchBounceAnimator = ValueAnimator.ofFloat(1.0f, 0.93f, 1.05f, 1.0f).apply { + duration = 260 + interpolator = OvershootInterpolator(1.4f) + addUpdateListener { animator -> + animatedScaleBounce = animator.animatedValue as Float + invalidate() + } + start() + } + } + + private var activeFeedbackType: DuoFeedbackType = DuoFeedbackType.NONE + private var feedbackProgress: Float = 0f + private var feedbackAnimator: ValueAnimator? = null + + private var chargeProgress: Float = 0f + private var chargeAnimator: ValueAnimator? = null + + private var isRotaryVisible: Boolean = false + private var rotaryLevel: Float = 0f + private var rotaryAlpha: Float = 0f + private var rotaryAnimator: ValueAnimator? = null + private val rotaryFadeRunnable = Runnable { fadeOutRotary() } + + fun triggerActionFeedback(type: DuoFeedbackType) { + if (type == DuoFeedbackType.NONE) return + feedbackAnimator?.cancel() + activeFeedbackType = type + feedbackProgress = 0f + + val targetDuration: Long = when (type) { + DuoFeedbackType.SCREENSHOT -> 350L + DuoFeedbackType.LOCK_SCREEN -> 320L + DuoFeedbackType.NOTIFICATIONS -> 380L + DuoFeedbackType.QUICK_SETTINGS -> 300L + DuoFeedbackType.MEDIA_PLAY, DuoFeedbackType.MEDIA_PAUSE -> 360L + DuoFeedbackType.TRACK_NEXT, DuoFeedbackType.TRACK_PREV -> 320L + DuoFeedbackType.TORCH_ON -> 380L + DuoFeedbackType.TORCH_OFF -> 260L + DuoFeedbackType.RECENTS -> 320L + DuoFeedbackType.NONE -> return + } + + val interp = when (type) { + DuoFeedbackType.LOCK_SCREEN -> OvershootInterpolator(1.8f) + DuoFeedbackType.TORCH_ON -> OvershootInterpolator(1.3f) + DuoFeedbackType.SCREENSHOT -> AccelerateDecelerateInterpolator() + DuoFeedbackType.TORCH_OFF -> AccelerateInterpolator() + else -> DecelerateInterpolator() + } + + feedbackAnimator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = targetDuration + interpolator = interp + addUpdateListener { animator -> + feedbackProgress = animator.animatedValue as Float + invalidate() + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + activeFeedbackType = DuoFeedbackType.NONE + feedbackProgress = 0f + invalidate() + } + }) + start() + } + } + + fun setChargeProgress(progress: Float) { + chargeAnimator?.cancel() + if (progress <= 0f && chargeProgress > 0f) { + chargeAnimator = ValueAnimator.ofFloat(chargeProgress, 0f).apply { + duration = 180L + interpolator = DecelerateInterpolator() + addUpdateListener { animator -> + chargeProgress = animator.animatedValue as Float + invalidate() + } + start() + } + } else { + chargeProgress = progress.coerceIn(0f, 1f) + invalidate() + } + } + + fun showRotaryLevel(level: Float, isVolume: Boolean = true) { + rotaryLevel = level.coerceIn(0f, 1f) + removeCallbacks(rotaryFadeRunnable) + if (!isRotaryVisible || rotaryAlpha < 0.99f) { + rotaryAnimator?.cancel() + isRotaryVisible = true + rotaryAnimator = ValueAnimator.ofFloat(rotaryAlpha, 1.0f).apply { + duration = 150L + interpolator = DecelerateInterpolator() + addUpdateListener { animator -> + rotaryAlpha = animator.animatedValue as Float + invalidate() + } + start() + } + } else { + invalidate() + } + postDelayed(rotaryFadeRunnable, 1200L) + } + + private fun fadeOutRotary() { + rotaryAnimator?.cancel() + rotaryAnimator = ValueAnimator.ofFloat(rotaryAlpha, 0f).apply { + duration = 250L + interpolator = DecelerateInterpolator() + addUpdateListener { animator -> + rotaryAlpha = animator.animatedValue as Float + invalidate() + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + isRotaryVisible = false + rotaryAlpha = 0f + invalidate() + } + }) + start() + } + } + private var targetProgress: Float = 100f private var animatedProgress: Float = 100f private var progressAnimator: ValueAnimator? = null @@ -625,6 +775,37 @@ class DuoOverlayView(context: Context) : View(context) { private val iconRect = RectF() private val arcBounds = RectF() + private val feedbackStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + } + private val feedbackFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + private val feedbackBounds = RectF() + private val teardropPath = Path() + private val chevronPath = Path() + + private val chargePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + } + private val chargeBounds = RectF() + + private val rotaryTrackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + } + private val rotaryProgressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + } + private val rotaryTickPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + } + private val rotaryBounds = RectF() + init { val (track, progress, dot) = getTargetColors() currentTrackColor = track @@ -730,6 +911,249 @@ class DuoOverlayView(context: Context) : View(context) { } } + // Long Press Charge Ring + if (chargeProgress > 0.01f) { + chargePaint.strokeWidth = 3.5f * density + val chargeAlpha = (240 * chargeProgress * animatedVisibilityAlpha).toInt().coerceIn(0, 255) + chargePaint.color = Color.argb( + chargeAlpha, + Color.red(currentProgressColor), + Color.green(currentProgressColor), + Color.blue(currentProgressColor) + ) + val chargeRadius = baseRadius + 3.5f * density + chargeBounds.set( + cameraCenterX - chargeRadius, + cameraCenterY - chargeRadius, + cameraCenterX + chargeRadius, + cameraCenterY + chargeRadius + ) + val chargeSweep = 360f * chargeProgress + canvas.drawArc(chargeBounds, 270f, chargeSweep, false, chargePaint) + } + + // Action-Specific Visual Feedback + if (feedbackProgress > 0.001f && activeFeedbackType != DuoFeedbackType.NONE) { + when (activeFeedbackType) { + DuoFeedbackType.SCREENSHOT -> { + val flashAlpha = (220 * (1f - feedbackProgress) * animatedVisibilityAlpha).toInt().coerceIn(0, 255) + feedbackFillPaint.color = Color.argb(flashAlpha, 255, 255, 255) + val flashRadius = cameraRadiusPx * (1f + 0.35f * feedbackProgress) + canvas.drawCircle(cameraCenterX, cameraCenterY, flashRadius, feedbackFillPaint) + + feedbackStrokePaint.strokeWidth = 2f * density + feedbackStrokePaint.color = Color.argb(flashAlpha, 255, 255, 255) + for (i in 0..5) { + val angle = i * 60f + (45f * feedbackProgress) + val rad = Math.toRadians(angle.toDouble()) + val cosVal = cos(rad).toFloat() + val sinVal = sin(rad).toFloat() + val x1 = cameraCenterX + cameraRadiusPx * 0.7f * cosVal + val y1 = cameraCenterY + cameraRadiusPx * 0.7f * sinVal + val x2 = cameraCenterX + (baseRadius + 6f * density) * cosVal + val y2 = cameraCenterY + (baseRadius + 6f * density) * sinVal + canvas.drawLine(x1, y1, x2, y2, feedbackStrokePaint) + } + } + + DuoFeedbackType.LOCK_SCREEN -> { + val lockScale = 1f - 0.20f * sin(feedbackProgress * Math.PI.toFloat()) + val lockRadius = baseRadius * lockScale + val lockAlpha = (220 * (1f - feedbackProgress) * animatedVisibilityAlpha).toInt().coerceIn(0, 255) + feedbackStrokePaint.strokeWidth = 4f * density + feedbackStrokePaint.color = Color.argb(lockAlpha, 0, 229, 255) + feedbackBounds.set( + cameraCenterX - lockRadius, + cameraCenterY - lockRadius, + cameraCenterX + lockRadius, + cameraCenterY + lockRadius + ) + canvas.drawArc(feedbackBounds, 0f, 360f, false, feedbackStrokePaint) + } + + DuoFeedbackType.NOTIFICATIONS -> { + val dropAlpha = (220 * (1f - feedbackProgress) * animatedVisibilityAlpha).toInt().coerceIn(0, 255) + feedbackFillPaint.color = Color.argb( + dropAlpha, + Color.red(currentProgressColor), + Color.green(currentProgressColor), + Color.blue(currentProgressColor) + ) + val dropExtension = 26f * density * sin(feedbackProgress * Math.PI.toFloat()) + val leftX = cameraCenterX - 12f * density + val rightX = cameraCenterX + 12f * density + val topY = cameraCenterY + baseRadius + val tipY = topY + dropExtension + teardropPath.reset() + teardropPath.moveTo(leftX, topY) + teardropPath.cubicTo(leftX, topY + dropExtension * 0.5f, cameraCenterX - 2f * density, tipY, cameraCenterX, tipY) + teardropPath.cubicTo(cameraCenterX + 2f * density, tipY, rightX, topY + dropExtension * 0.5f, rightX, topY) + teardropPath.close() + canvas.drawPath(teardropPath, feedbackFillPaint) + } + + DuoFeedbackType.MEDIA_PLAY, DuoFeedbackType.MEDIA_PAUSE -> { + val rippleRadius = baseRadius + (28f * density * feedbackProgress) + val rippleAlpha = (200 * (1f - feedbackProgress) * animatedVisibilityAlpha).toInt().coerceIn(0, 255) + feedbackStrokePaint.strokeWidth = (5f * density * (1f - feedbackProgress)).coerceAtLeast(1.5f) + feedbackStrokePaint.color = Color.argb( + rippleAlpha, + Color.red(currentProgressColor), + Color.green(currentProgressColor), + Color.blue(currentProgressColor) + ) + canvas.drawCircle(cameraCenterX, cameraCenterY, rippleRadius, feedbackStrokePaint) + } + + DuoFeedbackType.TRACK_NEXT, DuoFeedbackType.TRACK_PREV -> { + val isNext = activeFeedbackType == DuoFeedbackType.TRACK_NEXT + val chevronSweepAngle = if (isNext) 45f * feedbackProgress else -45f * feedbackProgress + val baseAngle = (if (isNext) 250f else 290f) + chevronSweepAngle + val chevAlpha = (240 * (1f - feedbackProgress) * animatedVisibilityAlpha).toInt().coerceIn(0, 255) + feedbackStrokePaint.strokeWidth = 3f * density + feedbackStrokePaint.color = Color.argb( + chevAlpha, + Color.red(currentProgressColor), + Color.green(currentProgressColor), + Color.blue(currentProgressColor) + ) + val offsets = if (isNext) floatArrayOf(-8f, 8f) else floatArrayOf(8f, -8f) + for (offset in offsets) { + val rad = Math.toRadians((baseAngle + offset).toDouble()) + val cx = (cameraCenterX + baseRadius * cos(rad)).toFloat() + val cy = (cameraCenterY + baseRadius * sin(rad)).toFloat() + val wingLen = 6f * density + val dir = if (isNext) 1f else -1f + val tangentAngle = baseAngle + 90f + val tanRad = Math.toRadians(tangentAngle.toDouble()) + val normRad = Math.toRadians(baseAngle.toDouble()) + val cosTan = cos(tanRad).toFloat() + val sinTan = sin(tanRad).toFloat() + val cosNorm = cos(normRad).toFloat() + val sinNorm = sin(normRad).toFloat() + + chevronPath.reset() + chevronPath.moveTo( + cx - (wingLen * 0.7f * cosTan * dir - wingLen * 0.7f * cosNorm), + cy - (wingLen * 0.7f * sinTan * dir - wingLen * 0.7f * sinNorm) + ) + chevronPath.lineTo(cx, cy) + chevronPath.lineTo( + cx - (wingLen * 0.7f * cosTan * dir + wingLen * 0.7f * cosNorm), + cy - (wingLen * 0.7f * sinTan * dir + wingLen * 0.7f * sinNorm) + ) + canvas.drawPath(chevronPath, feedbackStrokePaint) + } + } + + DuoFeedbackType.TORCH_ON, DuoFeedbackType.TORCH_OFF -> { + val isTorchOnAnim = activeFeedbackType == DuoFeedbackType.TORCH_ON + val flareAlpha = (if (isTorchOnAnim) 220 * (1f - feedbackProgress) else 180 * (1f - feedbackProgress)).toInt().coerceIn(0, 255) + feedbackStrokePaint.strokeWidth = 2.5f * density + feedbackStrokePaint.color = Color.argb(flareAlpha, 255, 193, 7) + for (ray in 0..7) { + val angleDeg = ray * 45f + (feedbackProgress * 20f) + val rad = Math.toRadians(angleDeg.toDouble()) + val cosVal = cos(rad).toFloat() + val sinVal = sin(rad).toFloat() + val rStart = baseRadius + 2f * density + val rEnd = rStart + (16f * density * (if (isTorchOnAnim) feedbackProgress else (1f - feedbackProgress))) + val x1 = cameraCenterX + rStart * cosVal + val y1 = cameraCenterY + rStart * sinVal + val x2 = cameraCenterX + rEnd * cosVal + val y2 = cameraCenterY + rEnd * sinVal + canvas.drawLine(x1, y1, x2, y2, feedbackStrokePaint) + } + } + + DuoFeedbackType.RECENTS -> { + val recAlpha = (210 * (1f - feedbackProgress) * animatedVisibilityAlpha).toInt().coerceIn(0, 255) + feedbackStrokePaint.strokeWidth = 3f * density + feedbackStrokePaint.color = Color.argb( + recAlpha, + Color.red(currentProgressColor), + Color.green(currentProgressColor), + Color.blue(currentProgressColor) + ) + val recRadius = baseRadius + (12f * density * feedbackProgress) + feedbackBounds.set( + cameraCenterX - recRadius, + cameraCenterY - recRadius, + cameraCenterX + recRadius, + cameraCenterY + recRadius + ) + canvas.drawArc(feedbackBounds, 140f, 80f, false, feedbackStrokePaint) + canvas.drawArc(feedbackBounds, 320f, 80f, false, feedbackStrokePaint) + } + + DuoFeedbackType.QUICK_SETTINGS -> { + val qsAlpha = (210 * (1f - feedbackProgress) * animatedVisibilityAlpha).toInt().coerceIn(0, 255) + feedbackStrokePaint.strokeWidth = 3.5f * density + feedbackStrokePaint.color = Color.argb( + qsAlpha, + Color.red(currentProgressColor), + Color.green(currentProgressColor), + Color.blue(currentProgressColor) + ) + val qsSpread = 16f * density * feedbackProgress + val yPos = cameraCenterY + canvas.drawLine(cameraCenterX - baseRadius, yPos, cameraCenterX - baseRadius - qsSpread, yPos, feedbackStrokePaint) + canvas.drawLine(cameraCenterX + baseRadius, yPos, cameraCenterX + baseRadius + qsSpread, yPos, feedbackStrokePaint) + } + + DuoFeedbackType.NONE -> {} + } + } + + // Rotary Virtual Dial Gauge + if (rotaryAlpha > 0.01f) { + val dialRadius = baseRadius + 10f * density + rotaryBounds.set( + cameraCenterX - dialRadius, + cameraCenterY - dialRadius, + cameraCenterX + dialRadius, + cameraCenterY + dialRadius + ) + val dialStartAngle = 150f + val dialTotalSweep = 240f + + rotaryTrackPaint.strokeWidth = 3.5f * density + val trackA = (70 * rotaryAlpha * animatedVisibilityAlpha).toInt().coerceIn(0, 255) + rotaryTrackPaint.color = Color.argb(trackA, 255, 255, 255) + canvas.drawArc(rotaryBounds, dialStartAngle, dialTotalSweep, false, rotaryTrackPaint) + + val activeSweep = dialTotalSweep * rotaryLevel + if (activeSweep > 0.5f) { + rotaryProgressPaint.strokeWidth = 4f * density + val progA = (240 * rotaryAlpha * animatedVisibilityAlpha).toInt().coerceIn(0, 255) + rotaryProgressPaint.color = Color.argb( + progA, + Color.red(currentProgressColor), + Color.green(currentProgressColor), + Color.blue(currentProgressColor) + ) + canvas.drawArc(rotaryBounds, dialStartAngle, activeSweep, false, rotaryProgressPaint) + } + + rotaryTickPaint.strokeWidth = 2f * density + val tickA = (120 * rotaryAlpha * animatedVisibilityAlpha).toInt().coerceIn(0, 255) + rotaryTickPaint.color = Color.argb(tickA, 255, 255, 255) + val stepDegrees = dialTotalSweep / 6f + for (step in 0..6) { + val tickAngle = dialStartAngle + stepDegrees * step + val tickRad = Math.toRadians(tickAngle.toDouble()) + val cosVal = cos(tickRad).toFloat() + val sinVal = sin(tickRad).toFloat() + val rInner = dialRadius - 3.5f * density + val rOuter = dialRadius + 3.5f * density + val tx1 = cameraCenterX + rInner * cosVal + val ty1 = cameraCenterY + rInner * sinVal + val tx2 = cameraCenterX + rOuter * cosVal + val ty2 = cameraCenterY + rOuter * sinVal + canvas.drawLine(tx1, ty1, tx2, ty2, rotaryTickPaint) + } + } + canvas.restore() } @@ -741,6 +1165,11 @@ class DuoOverlayView(context: Context) : View(context) { scaleAnimator?.cancel() themeAnimator?.cancel() visibilityAnimator?.cancel() + touchBounceAnimator?.cancel() + feedbackAnimator?.cancel() + chargeAnimator?.cancel() + rotaryAnimator?.cancel() + removeCallbacks(rotaryFadeRunnable) } } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/DuoTouchAnchorView.kt b/app/src/main/java/com/sameerasw/essentials/utils/DuoTouchAnchorView.kt new file mode 100644 index 0000000000..58a3611b21 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/DuoTouchAnchorView.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Utilities - Overlays + * File: DuoTouchAnchorView.kt + * Description: Circular touch anchor view positioned over the camera cutout for gesture dispatching. + */ + +package com.sameerasw.essentials.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.os.SystemClock +import android.view.GestureDetector +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.hypot + +/** + * Lightweight touch anchor view positioned strictly over the camera cutout. + * Intercepts gestures (single tap, double tap, long press, horizontal swipe, + * and virtual rotary dial dragging) only within its circular radius, passing + * all surrounding screen touches through. + */ +class DuoTouchAnchorView(context: Context) : View(context) { + + var onSingleTap: (() -> Unit)? = null + var onDoubleTap: (() -> Unit)? = null + var onLongPress: (() -> Unit)? = null + var onSwipeRight: (() -> Unit)? = null + var onSwipeLeft: (() -> Unit)? = null + var onTouchDown: (() -> Unit)? = null + var onRotaryStart: (() -> Unit)? = null + var onRotaryDelta: ((deltaAngle: Float) -> Unit)? = null + var onRotaryEnd: (() -> Unit)? = null + var onLongPressProgress: ((fraction: Float) -> Unit)? = null + + var isHapticEnabled: Boolean = true + var isRotaryEnabled: Boolean = true + + private var isTouchActive = false + private var isRotaryActive = false + private var touchDownTime = 0L + private var lastAngleDeg = 0f + private var accumulatedRotaryAngle = 0f + private var hapticAngleAccumulator = 0f + + private val longPressProgressRunnable = object : Runnable { + override fun run() { + if (!isTouchActive || isRotaryActive) return + val elapsed = SystemClock.uptimeMillis() - touchDownTime + val fraction = (elapsed / 500f).coerceIn(0f, 1f) + onLongPressProgress?.invoke(fraction) + if (elapsed < 500L) { + postDelayed(this, 16L) + } + } + } + + private val gestureDetector = GestureDetector( + context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent): Boolean = true + + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + onSingleTap?.invoke() + return true + } + + override fun onDoubleTap(e: MotionEvent): Boolean { + onDoubleTap?.invoke() + return true + } + + override fun onLongPress(e: MotionEvent) { + removeCallbacks(longPressProgressRunnable) + onLongPressProgress?.invoke(1.0f) + performTactileHaptic(HapticFeedbackConstants.LONG_PRESS) + onLongPress?.invoke() + } + + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float, + ): Boolean { + if (e1 == null) return false + val deltaX = e2.x - e1.x + val deltaY = e2.y - e1.y + + if (abs(deltaX) > abs(deltaY) && abs(velocityX) > 400f) { + if (deltaX > 0) { + onSwipeRight?.invoke() + return true + } else { + onSwipeLeft?.invoke() + return true + } + } + return false + } + }, + ) + + fun performTactileHaptic(feedbackConstant: Int = HapticFeedbackConstants.KEYBOARD_TAP) { + if (isHapticEnabled) { + performHapticFeedback(feedbackConstant) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + val centerX = width / 2f + val centerY = height / 2f + val radius = width.coerceAtMost(height) / 2f + val dx = event.x - centerX + val dy = event.y - centerY + val dist = hypot(dx, dy) + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + if (dist > radius) { + return false + } + isTouchActive = true + isRotaryActive = false + accumulatedRotaryAngle = 0f + hapticAngleAccumulator = 0f + touchDownTime = SystemClock.uptimeMillis() + lastAngleDeg = Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() + performTactileHaptic() + onTouchDown?.invoke() + removeCallbacks(longPressProgressRunnable) + post(longPressProgressRunnable) + } + + MotionEvent.ACTION_MOVE -> { + if (!isTouchActive) return false + val currentAngleDeg = Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() + var delta = currentAngleDeg - lastAngleDeg + if (delta > 180f) delta -= 360f + else if (delta < -180f) delta += 360f + lastAngleDeg = currentAngleDeg + + if (isRotaryEnabled && dist >= radius * 0.25f && dist <= radius * 1.35f) { + if (!isRotaryActive) { + accumulatedRotaryAngle += abs(delta) + if (accumulatedRotaryAngle >= 16f) { + isRotaryActive = true + removeCallbacks(longPressProgressRunnable) + onLongPressProgress?.invoke(0f) + onRotaryStart?.invoke() + } + } + if (isRotaryActive) { + onRotaryDelta?.invoke(delta) + hapticAngleAccumulator += delta + if (abs(hapticAngleAccumulator) >= 12f) { + performTactileHaptic(HapticFeedbackConstants.CLOCK_TICK) + hapticAngleAccumulator = 0f + } + return true + } + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + val wasRotary = isRotaryActive + isTouchActive = false + isRotaryActive = false + removeCallbacks(longPressProgressRunnable) + onLongPressProgress?.invoke(0f) + if (wasRotary) { + onRotaryEnd?.invoke() + return true + } + } + } + + return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + removeCallbacks(longPressProgressRunnable) + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 9bfd1fdfe8..a0c9552e4b 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -138,6 +138,12 @@ class MainViewModel : ViewModel() { val isDuoHideWhenScreenOff = mutableStateOf(true) val isDuoHideWhenScreenOffOnlyIdle = mutableStateOf(false) val isDuoUseMaterialYou = mutableStateOf(true) + val isDuoEnableGestures = mutableStateOf(true) + val isDuoGestureHaptic = mutableStateOf(true) + val isDuoGestureAnimations = mutableStateOf(true) + val isDuoRotaryDial = mutableStateOf(true) + val duoGestureSingleTapAction = mutableStateOf("notifications") + val duoGestureDoubleTapAction = mutableStateOf("lock_screen") val snoozeChannels = mutableStateOf>(emptyList()) val mapsChannels = @@ -564,6 +570,24 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_DUO_USE_MATERIAL_YOU -> isDuoUseMaterialYou.value = settingsRepository.isDuoUseMaterialYouEnabled() + SettingsRepository.KEY_DUO_ENABLE_GESTURES -> + isDuoEnableGestures.value = settingsRepository.isDuoEnableGesturesEnabled() + + SettingsRepository.KEY_DUO_GESTURE_HAPTIC -> + isDuoGestureHaptic.value = settingsRepository.isDuoGestureHapticEnabled() + + SettingsRepository.KEY_DUO_GESTURE_ANIMATIONS -> + isDuoGestureAnimations.value = settingsRepository.isDuoGestureAnimationsEnabled() + + SettingsRepository.KEY_DUO_ROTARY_DIAL -> + isDuoRotaryDial.value = settingsRepository.isDuoRotaryDialEnabled() + + SettingsRepository.KEY_DUO_GESTURE_SINGLE_TAP -> + duoGestureSingleTapAction.value = settingsRepository.getDuoGestureSingleTapAction() + + SettingsRepository.KEY_DUO_GESTURE_DOUBLE_TAP -> + duoGestureDoubleTapAction.value = settingsRepository.getDuoGestureDoubleTapAction() + SettingsRepository.KEY_SCREEN_LOCKED_SECURITY_ENABLED -> isScreenLockedSecurityEnabled.value = settingsRepository.getBoolean(key) @@ -1801,6 +1825,12 @@ class MainViewModel : ViewModel() { isDuoHideWhenScreenOff.value = settingsRepository.isDuoHideWhenScreenOffEnabled() isDuoHideWhenScreenOffOnlyIdle.value = settingsRepository.isDuoHideWhenScreenOffOnlyIdleEnabled() isDuoUseMaterialYou.value = settingsRepository.isDuoUseMaterialYouEnabled() + isDuoEnableGestures.value = settingsRepository.isDuoEnableGesturesEnabled() + isDuoGestureHaptic.value = settingsRepository.isDuoGestureHapticEnabled() + isDuoGestureAnimations.value = settingsRepository.isDuoGestureAnimationsEnabled() + isDuoRotaryDial.value = settingsRepository.isDuoRotaryDialEnabled() + duoGestureSingleTapAction.value = settingsRepository.getDuoGestureSingleTapAction() + duoGestureDoubleTapAction.value = settingsRepository.getDuoGestureDoubleTapAction() loadSnoozeChannels(context) loadMapsChannels(context) isSnoozeHeadsUpEnabled.value = @@ -4432,6 +4462,36 @@ class MainViewModel : ViewModel() { settingsRepository.setDuoUseMaterialYouEnabled(enabled) } + fun setDuoEnableGestures(enabled: Boolean) { + isDuoEnableGestures.value = enabled + settingsRepository.setDuoEnableGesturesEnabled(enabled) + } + + fun setDuoGestureHaptic(enabled: Boolean) { + isDuoGestureHaptic.value = enabled + settingsRepository.setDuoGestureHapticEnabled(enabled) + } + + fun setDuoGestureAnimations(enabled: Boolean) { + isDuoGestureAnimations.value = enabled + settingsRepository.setDuoGestureAnimationsEnabled(enabled) + } + + fun setDuoRotaryDial(enabled: Boolean) { + isDuoRotaryDial.value = enabled + settingsRepository.setDuoRotaryDialEnabled(enabled) + } + + fun setDuoGestureSingleTapAction(action: String) { + duoGestureSingleTapAction.value = action + settingsRepository.setDuoGestureSingleTapAction(action) + } + + fun setDuoGestureDoubleTapAction(action: String) { + duoGestureDoubleTapAction.value = action + settingsRepository.setDuoGestureDoubleTapAction(action) + } + /** * Executes the set app lock enabled operation. * diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index befd87c983..7c539a3a46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2479,4 +2479,28 @@ Use Material You colors Hide when screen off Only hide on AOD while idle + Keep active media, progress notifications, or flashlight visible on AOD + + + Gestures + Cutout touch gestures + Interact with your camera cutout using tap, double tap, and swipe + Vibrate on touch + Play tactile haptic feedback when tapping the cutout + Gesture visual effects + Play dynamic visual feedback around the cutout for each gesture + Virtual rotary dial + Rotate around the camera cutout to adjust media volume or flashlight + Single tap action + Action when tapped while idle + Double tap action + Action when double tapped while idle + + Notification shade + Quick settings + Take screenshot + Lock screen + Toggle flashlight + Recents + None