feat(bubble): drag-to-dismiss zone, no auto-timeout, dismiss on conversation enter
- MessageBubbleOverlay: drag-to-dismiss target at bottom-center, no 12s auto-timeout (persists until read) - MessageBubbleController: dismissFor clears bubble when its conversation is opened - ActiveThreadMonitor: calls dismissFor on enter, clearing the bubble for that thread
This commit is contained in:
parent
adb61715fe
commit
608ddcfc5b
|
|
@ -12,13 +12,19 @@ import javax.inject.Singleton
|
||||||
* notification and this monitor isn't consulted — which is the desired behaviour.
|
* notification and this monitor isn't consulted — which is the desired behaviour.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ActiveThreadMonitor @Inject constructor() {
|
class ActiveThreadMonitor @Inject constructor(
|
||||||
|
private val messageBubbleController: MessageBubbleController
|
||||||
|
) {
|
||||||
@Volatile
|
@Volatile
|
||||||
var activeQuestionId: String? = null
|
var activeQuestionId: String? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
fun enter(questionId: String) {
|
fun enter(questionId: String) {
|
||||||
if (questionId.isNotBlank()) activeQuestionId = questionId
|
if (questionId.isNotBlank()) {
|
||||||
|
activeQuestionId = questionId
|
||||||
|
// Opening the conversation counts as reading it — clear any chat bubble for it.
|
||||||
|
messageBubbleController.dismissFor(questionId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun leave(questionId: String) {
|
fun leave(questionId: String) {
|
||||||
|
|
|
||||||
|
|
@ -40,4 +40,9 @@ class MessageBubbleController @Inject constructor() {
|
||||||
fun dismiss() {
|
fun dismiss() {
|
||||||
_bubble.value = null
|
_bubble.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clear the bubble once its conversation is opened (the message has been read). */
|
||||||
|
fun dismissFor(questionId: String) {
|
||||||
|
_bubble.update { current -> if (current?.questionId == questionId) null else current }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,15 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -40,7 +41,6 @@ import app.closer.notifications.MessageBubbleController
|
||||||
import app.closer.ui.theme.CloserPalette
|
import app.closer.ui.theme.CloserPalette
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|
@ -80,10 +80,33 @@ fun MessageBubbleOverlay(
|
||||||
var offsetX by remember(current.questionId) { mutableFloatStateOf(rightEdge) }
|
var offsetX by remember(current.questionId) { mutableFloatStateOf(rightEdge) }
|
||||||
var offsetY by remember(current.questionId) { mutableFloatStateOf(maxYpx * 0.32f) }
|
var offsetY by remember(current.questionId) { mutableFloatStateOf(maxYpx * 0.32f) }
|
||||||
|
|
||||||
// Auto-dismiss if the user neither opens nor moves it for a while.
|
// The bubble persists until the message is read — opening the conversation clears it (via
|
||||||
LaunchedEffect(current.questionId, current.count) {
|
// ActiveThreadMonitor) — or the user flicks it down onto the dismiss target. No timeout.
|
||||||
delay(12_000)
|
var dragging by remember(current.questionId) { mutableStateOf(false) }
|
||||||
viewModel.dismiss()
|
var nearDismiss by remember(current.questionId) { mutableStateOf(false) }
|
||||||
|
val dismissZonePx = with(density) { 150.dp.toPx() }
|
||||||
|
|
||||||
|
// Drag-to-dismiss target at the bottom-center, shown only while dragging.
|
||||||
|
if (dragging) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 48.dp)
|
||||||
|
.size(if (nearDismiss) 66.dp else 54.dp)
|
||||||
|
.shadow(6.dp, CircleShape)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (nearDismiss) CloserPalette.PinkAccentDeep else Color.Black.copy(alpha = 0.45f)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Close,
|
||||||
|
contentDescription = "Dismiss",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -96,15 +119,27 @@ fun MessageBubbleOverlay(
|
||||||
.border(2.5.dp, Color.White, CircleShape)
|
.border(2.5.dp, Color.White, CircleShape)
|
||||||
.pointerInput(current.questionId) {
|
.pointerInput(current.questionId) {
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
|
onDragStart = { dragging = true },
|
||||||
onDrag = { change, drag ->
|
onDrag = { change, drag ->
|
||||||
change.consume()
|
change.consume()
|
||||||
offsetX = (offsetX + drag.x).coerceIn(0f, rightEdge)
|
offsetX = (offsetX + drag.x).coerceIn(0f, rightEdge)
|
||||||
offsetY = (offsetY + drag.y).coerceIn(marginPx, maxYpx - sizePx - marginPx)
|
offsetY = (offsetY + drag.y).coerceIn(marginPx, maxYpx - sizePx - marginPx)
|
||||||
|
nearDismiss = (offsetY + sizePx) > (maxYpx - dismissZonePx)
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
|
dragging = false
|
||||||
|
if (nearDismiss) {
|
||||||
|
viewModel.dismiss()
|
||||||
|
} else {
|
||||||
// Snap to whichever side is closer (chat-head behaviour).
|
// Snap to whichever side is closer (chat-head behaviour).
|
||||||
offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge
|
offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge
|
||||||
}
|
}
|
||||||
|
nearDismiss = false
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
dragging = false
|
||||||
|
nearDismiss = false
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.pointerInput(current.questionId) {
|
.pointerInput(current.questionId) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue