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.
|
||||
*/
|
||||
@Singleton
|
||||
class ActiveThreadMonitor @Inject constructor() {
|
||||
class ActiveThreadMonitor @Inject constructor(
|
||||
private val messageBubbleController: MessageBubbleController
|
||||
) {
|
||||
@Volatile
|
||||
var activeQuestionId: String? = null
|
||||
private set
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -40,4 +40,9 @@ class MessageBubbleController @Inject constructor() {
|
|||
fun dismiss() {
|
||||
_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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -40,7 +41,6 @@ import app.closer.notifications.MessageBubbleController
|
|||
import app.closer.ui.theme.CloserPalette
|
||||
import coil.compose.AsyncImage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
|
@ -80,10 +80,33 @@ fun MessageBubbleOverlay(
|
|||
var offsetX by remember(current.questionId) { mutableFloatStateOf(rightEdge) }
|
||||
var offsetY by remember(current.questionId) { mutableFloatStateOf(maxYpx * 0.32f) }
|
||||
|
||||
// Auto-dismiss if the user neither opens nor moves it for a while.
|
||||
LaunchedEffect(current.questionId, current.count) {
|
||||
delay(12_000)
|
||||
viewModel.dismiss()
|
||||
// The bubble persists until the message is read — opening the conversation clears it (via
|
||||
// ActiveThreadMonitor) — or the user flicks it down onto the dismiss target. No timeout.
|
||||
var dragging by remember(current.questionId) { mutableStateOf(false) }
|
||||
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(
|
||||
|
|
@ -96,14 +119,26 @@ fun MessageBubbleOverlay(
|
|||
.border(2.5.dp, Color.White, CircleShape)
|
||||
.pointerInput(current.questionId) {
|
||||
detectDragGestures(
|
||||
onDragStart = { dragging = true },
|
||||
onDrag = { change, drag ->
|
||||
change.consume()
|
||||
offsetX = (offsetX + drag.x).coerceIn(0f, rightEdge)
|
||||
offsetY = (offsetY + drag.y).coerceIn(marginPx, maxYpx - sizePx - marginPx)
|
||||
nearDismiss = (offsetY + sizePx) > (maxYpx - dismissZonePx)
|
||||
},
|
||||
onDragEnd = {
|
||||
// Snap to whichever side is closer (chat-head behaviour).
|
||||
offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge
|
||||
dragging = false
|
||||
if (nearDismiss) {
|
||||
viewModel.dismiss()
|
||||
} else {
|
||||
// Snap to whichever side is closer (chat-head behaviour).
|
||||
offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge
|
||||
}
|
||||
nearDismiss = false
|
||||
},
|
||||
onDragCancel = {
|
||||
dragging = false
|
||||
nearDismiss = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue