diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.android.kt new file mode 100644 index 00000000..486f1477 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.android.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.profiles + +internal actual object ProfileHoverHapticFeedback { + actual fun prepare() = Unit + actual fun perform() = Unit + actual fun release() = Unit +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.kt new file mode 100644 index 00000000..1c939a2e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.profiles + +internal expect object ProfileHoverHapticFeedback { + fun prepare() + fun perform() + fun release() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index cecd6273..3678398e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -14,7 +14,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -40,6 +40,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -48,10 +49,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight @@ -64,6 +70,7 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.isIos import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* @@ -97,6 +104,52 @@ fun ProfileSwitcherTab( // Keep popup composed while exit animation plays var popupVisible by remember { mutableStateOf(false) } var pinProfile by remember { mutableStateOf(null) } + var dragTargetProfileIndex by remember { mutableStateOf(null) } + var triggerCoordinates by remember { mutableStateOf(null) } + val profileBubbleBounds = remember(profiles.map { it.profileIndex }) { + mutableStateMapOf() + } + + fun performProfileHoldHaptic() { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + + fun performProfileHoverHaptic() { + if (isIos) { + ProfileHoverHapticFeedback.perform() + } else { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + + fun updateDragTarget(localPosition: Offset) { + val trigger = triggerCoordinates ?: return + val windowPosition = trigger.localToWindow(localPosition) + val nextTargetProfileIndex = profileBubbleBounds.entries + .firstOrNull { (_, bounds) -> bounds.contains(windowPosition) } + ?.key + if (nextTargetProfileIndex != null && nextTargetProfileIndex != dragTargetProfileIndex) { + performProfileHoverHaptic() + } + dragTargetProfileIndex = nextTargetProfileIndex + } + + fun chooseProfile(profile: NuvioProfile) { + if (profile.pinEnabled) { + pinProfile = profile + } else { + onProfileSelected(profile) + showPopup = false + } + } + + fun chooseDragTarget() { + val profile = profiles.firstOrNull { it.profileIndex == dragTargetProfileIndex } + dragTargetProfileIndex = null + if (profile != null) { + chooseProfile(profile) + } + } // Popup entrance/exit animation val popupAlpha = remember { Animatable(0f) } @@ -126,6 +179,7 @@ fun ProfileSwitcherTab( ) } } else { + ProfileHoverHapticFeedback.release() // Animate out launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) } launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) } @@ -134,21 +188,41 @@ fun ProfileSwitcherTab( // Remove from composition after animation completes popupVisible = false pinProfile = null + dragTargetProfileIndex = null } } } Box( modifier = modifier + .onGloballyPositioned { triggerCoordinates = it } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) .pointerInput(profiles) { - detectTapGestures( - onTap = { onClick() }, - onLongPress = { + detectDragGesturesAfterLongPress( + onDragStart = { startOffset -> if (profiles.isNotEmpty()) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) + performProfileHoldHaptic() + ProfileHoverHapticFeedback.prepare() showPopup = true + updateDragTarget(startOffset) } }, + onDrag = { change, _ -> + change.consume() + updateDragTarget(change.position) + }, + onDragEnd = { + ProfileHoverHapticFeedback.release() + chooseDragTarget() + }, + onDragCancel = { + ProfileHoverHapticFeedback.release() + dragTargetProfileIndex = null + }, ) }, contentAlignment = Alignment.Center, @@ -199,20 +273,20 @@ fun ProfileSwitcherTab( profile.profileIndex == activeProfile?.profileIndex val isPinTarget = pinProfile?.profileIndex == profile.profileIndex + val isDragTarget = + dragTargetProfileIndex == profile.profileIndex PopupProfileBubble( profile = profile, avatars = avatars, isActive = isActive, - isSelected = isPinTarget, + isSelected = isPinTarget || isDragTarget, delayMs = index * 50, + onBoundsChanged = { bounds -> + profileBubbleBounds[profile.profileIndex] = bounds + }, onClick = { - if (profile.pinEnabled) { - pinProfile = profile - } else { - onProfileSelected(profile) - showPopup = false - } + chooseProfile(profile) }, ) } @@ -335,6 +409,7 @@ private fun PopupProfileBubble( isActive: Boolean, isSelected: Boolean, delayMs: Int, + onBoundsChanged: (Rect) -> Unit, onClick: () -> Unit, ) { val avatarColor = remember(profile.avatarColorHex) { parseHexColor(profile.avatarColorHex) } @@ -363,7 +438,7 @@ private fun PopupProfileBubble( } val pressScale by animateFloatAsState( - targetValue = if (isSelected) 1.15f else 1f, + targetValue = if (isSelected) 1.08f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow, @@ -374,6 +449,9 @@ private fun PopupProfileBubble( Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier + .onGloballyPositioned { coordinates -> + onBoundsChanged(coordinates.boundsInWindow()) + } .graphicsLayer { alpha = itemAlpha.value scaleX = itemScale.value * pressScale diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.ios.kt new file mode 100644 index 00000000..7bd03336 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.ios.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.features.profiles + +import platform.UIKit.UISelectionFeedbackGenerator + +internal actual object ProfileHoverHapticFeedback { + private var generator: UISelectionFeedbackGenerator? = null + + actual fun prepare() { + generator = UISelectionFeedbackGenerator().also { it.prepare() } + } + + actual fun perform() { + val activeGenerator = generator ?: UISelectionFeedbackGenerator().also { + generator = it + } + activeGenerator.selectionChanged() + activeGenerator.prepare() + } + + actual fun release() { + generator = null + } +}