mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
car toasts: take turns instead of replacing each other
A new toast waits until the one on screen has had 2 seconds. Repeats restart the visible toast, and a burst keeps only the newest three waiting.
This commit is contained in:
1 parent
75dfaa3013
commit
23f12884da
3 files changed
+75
-9
No files matched your search
@@ -20,6 +20,8 @@ import androidx.compose.ui.semantics.semantics
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
/** Draw inside the encoded car display; a normal Android Toast would appear on the phone. */
|
/** Draw inside the encoded car display; a normal Android Toast would appear on the phone. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -31,7 +33,11 @@ internal fun CarToastOverlay(modifier: Modifier = Modifier) {
|
|||||||
val timeout = accessibility?.calculateRecommendedTimeoutMillis(
|
val timeout = accessibility?.calculateRecommendedTimeoutMillis(
|
||||||
message.durationMs, containsIcons = false, containsText = true, containsControls = false,
|
message.durationMs, containsIcons = false, containsText = true, containsControls = false,
|
||||||
) ?: message.durationMs
|
) ?: message.durationMs
|
||||||
delay(timeout)
|
// The full time, unless another toast is waiting: then it gives way after the minimum.
|
||||||
|
delay(minOf(timeout, CarToasts.MIN_SHOWN_MS))
|
||||||
|
withTimeoutOrNull((timeout - CarToasts.MIN_SHOWN_MS).coerceAtLeast(0)) {
|
||||||
|
CarToasts.waiting.first { it > 0 }
|
||||||
|
}
|
||||||
CarToasts.dismiss(message)
|
CarToasts.dismiss(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,29 +6,60 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
/** One toast across navigation and browse apps; newer requests replace stale messages. */
|
/**
|
||||||
|
* One toast at a time across navigation and browse apps. A newer one waits its turn instead of
|
||||||
|
* replacing the one on screen, which gets at least [MIN_SHOWN_MS] before it gives way.
|
||||||
|
*/
|
||||||
@MainThread
|
@MainThread
|
||||||
internal object CarToasts {
|
internal object CarToasts {
|
||||||
// Identity matters: repeating identical text must restart its display timer.
|
// Identity matters: repeating identical text must restart its display timer.
|
||||||
class Message(val owner: Any, val text: String, val durationMs: Long)
|
class Message(val owner: Any, val text: String, val durationMs: Long)
|
||||||
|
|
||||||
|
/** Long enough to read a short toast at a glance; a waiting toast cuts the rest short. */
|
||||||
|
const val MIN_SHOWN_MS = 2_000L
|
||||||
|
|
||||||
|
/** A burst beyond this many is stale by the time it would show; the oldest waiting go first. */
|
||||||
|
private const val MAX_WAITING = 3
|
||||||
|
|
||||||
private val current = MutableStateFlow<Message?>(null)
|
private val current = MutableStateFlow<Message?>(null)
|
||||||
val message: StateFlow<Message?> = current.asStateFlow()
|
val message: StateFlow<Message?> = current.asStateFlow()
|
||||||
|
|
||||||
|
private val queue = ArrayDeque<Message>()
|
||||||
|
private val _waiting = MutableStateFlow(0)
|
||||||
|
val waiting: StateFlow<Int> = _waiting.asStateFlow()
|
||||||
|
|
||||||
fun show(owner: Any, text: String, duration: Int) {
|
fun show(owner: Any, text: String, duration: Int) {
|
||||||
if (text.isBlank()) return
|
if (text.isBlank()) return
|
||||||
// Longer than a phone toast: the driver only glances at the screen now and then.
|
// Longer than a phone toast: the driver only glances at the screen now and then.
|
||||||
val durationMs = if (duration == CarToast.LENGTH_LONG) 7_000L else 4_000L
|
val durationMs = if (duration == CarToast.LENGTH_LONG) 7_000L else 4_000L
|
||||||
current.value = Message(owner, text, durationMs)
|
val next = Message(owner, text, durationMs)
|
||||||
|
val shown = current.value
|
||||||
|
when {
|
||||||
|
shown == null -> current.value = next
|
||||||
|
// The same words again restart the one on screen rather than queueing a copy.
|
||||||
|
shown.text == text -> current.value = next
|
||||||
|
queue.lastOrNull()?.text == text -> Unit
|
||||||
|
else -> {
|
||||||
|
queue.addLast(next)
|
||||||
|
while (queue.size > MAX_WAITING) queue.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_waiting.value = queue.size
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismiss(message: Message) {
|
fun dismiss(message: Message) {
|
||||||
// An old timer must not dismiss a newer toast, even if its text is identical.
|
// An old timer must not dismiss a newer toast, even if its text is identical.
|
||||||
if (current.value === message) current.value = null
|
if (current.value === message) advance()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear(owner: Any) {
|
fun clear(owner: Any) {
|
||||||
// Disconnecting the map must not clear a toast belonging to the browse app.
|
// Disconnecting the map must not clear a toast belonging to the browse app.
|
||||||
if (current.value?.owner === owner) current.value = null
|
queue.removeAll { it.owner === owner }
|
||||||
|
if (current.value?.owner === owner) advance() else _waiting.value = queue.size
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun advance() {
|
||||||
|
current.value = queue.removeFirstOrNull()
|
||||||
|
_waiting.value = queue.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,15 +13,30 @@ class CarToastsTest {
|
|||||||
@Before
|
@Before
|
||||||
@After
|
@After
|
||||||
fun clearToast() {
|
fun clearToast() {
|
||||||
CarToasts.message.value?.let(CarToasts::dismiss)
|
while (true) CarToasts.message.value?.let(CarToasts::dismiss) ?: break
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun expiredToastCannotDismissItsReplacement() {
|
fun aNewToastWaitsForTheOneOnScreen() {
|
||||||
val owner = Any()
|
val owner = Any()
|
||||||
CarToasts.show(owner, "First", CarToast.LENGTH_SHORT)
|
CarToasts.show(owner, "First", CarToast.LENGTH_SHORT)
|
||||||
val first = requireNotNull(CarToasts.message.value)
|
val first = requireNotNull(CarToasts.message.value)
|
||||||
CarToasts.show(owner, "Second", CarToast.LENGTH_LONG)
|
CarToasts.show(owner, "Second", CarToast.LENGTH_LONG)
|
||||||
|
|
||||||
|
assertSame(first, CarToasts.message.value)
|
||||||
|
assertEquals(1, CarToasts.waiting.value)
|
||||||
|
CarToasts.dismiss(first)
|
||||||
|
assertEquals("Second", CarToasts.message.value?.text)
|
||||||
|
assertEquals(0, CarToasts.waiting.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun expiredTimerCannotDismissItsReplacement() {
|
||||||
|
val owner = Any()
|
||||||
|
CarToasts.show(owner, "First", CarToast.LENGTH_SHORT)
|
||||||
|
val first = requireNotNull(CarToasts.message.value)
|
||||||
|
CarToasts.show(owner, "Second", CarToast.LENGTH_LONG)
|
||||||
|
CarToasts.dismiss(first)
|
||||||
val second = requireNotNull(CarToasts.message.value)
|
val second = requireNotNull(CarToasts.message.value)
|
||||||
|
|
||||||
CarToasts.dismiss(first)
|
CarToasts.dismiss(first)
|
||||||
@@ -39,6 +54,7 @@ class CarToastsTest {
|
|||||||
val repeated = requireNotNull(CarToasts.message.value)
|
val repeated = requireNotNull(CarToasts.message.value)
|
||||||
|
|
||||||
assertNotSame(first, repeated)
|
assertNotSame(first, repeated)
|
||||||
|
assertEquals(0, CarToasts.waiting.value)
|
||||||
CarToasts.dismiss(first)
|
CarToasts.dismiss(first)
|
||||||
assertSame(repeated, CarToasts.message.value)
|
assertSame(repeated, CarToasts.message.value)
|
||||||
}
|
}
|
||||||
@@ -49,14 +65,24 @@ class CarToastsTest {
|
|||||||
val browse = Any()
|
val browse = Any()
|
||||||
CarToasts.show(nav, "Route saved", CarToast.LENGTH_SHORT)
|
CarToasts.show(nav, "Route saved", CarToast.LENGTH_SHORT)
|
||||||
CarToasts.show(browse, "Added to playlist", CarToast.LENGTH_SHORT)
|
CarToasts.show(browse, "Added to playlist", CarToast.LENGTH_SHORT)
|
||||||
val latest = CarToasts.message.value
|
|
||||||
|
|
||||||
CarToasts.clear(nav)
|
CarToasts.clear(nav)
|
||||||
assertSame(latest, CarToasts.message.value)
|
assertEquals("Added to playlist", CarToasts.message.value?.text)
|
||||||
CarToasts.clear(browse)
|
CarToasts.clear(browse)
|
||||||
assertNull(CarToasts.message.value)
|
assertNull(CarToasts.message.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBurstKeepsOnlyTheNewestFewWaiting() {
|
||||||
|
val owner = Any()
|
||||||
|
listOf("1", "2", "3", "4", "5", "6").forEach { CarToasts.show(owner, it, CarToast.LENGTH_SHORT) }
|
||||||
|
|
||||||
|
assertEquals("1", CarToasts.message.value?.text)
|
||||||
|
assertEquals(3, CarToasts.waiting.value)
|
||||||
|
CarToasts.message.value?.let(CarToasts::dismiss)
|
||||||
|
assertEquals("4", CarToasts.message.value?.text)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun blankRequestsDoNotReplaceAVisibleMessage() {
|
fun blankRequestsDoNotReplaceAVisibleMessage() {
|
||||||
val owner = Any()
|
val owner = Any()
|
||||||
@@ -65,6 +91,7 @@ class CarToastsTest {
|
|||||||
|
|
||||||
CarToasts.show(owner, " \n\t", CarToast.LENGTH_LONG)
|
CarToasts.show(owner, " \n\t", CarToast.LENGTH_LONG)
|
||||||
assertSame(visible, CarToasts.message.value)
|
assertSame(visible, CarToasts.message.value)
|
||||||
|
assertEquals(0, CarToasts.waiting.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -72,8 +99,10 @@ class CarToastsTest {
|
|||||||
val owner = Any()
|
val owner = Any()
|
||||||
CarToasts.show(owner, "Short", CarToast.LENGTH_SHORT)
|
CarToasts.show(owner, "Short", CarToast.LENGTH_SHORT)
|
||||||
assertEquals(4_000L, CarToasts.message.value?.durationMs)
|
assertEquals(4_000L, CarToasts.message.value?.durationMs)
|
||||||
|
CarToasts.message.value?.let(CarToasts::dismiss)
|
||||||
CarToasts.show(owner, "Long", CarToast.LENGTH_LONG)
|
CarToasts.show(owner, "Long", CarToast.LENGTH_LONG)
|
||||||
assertEquals(7_000L, CarToasts.message.value?.durationMs)
|
assertEquals(7_000L, CarToasts.message.value?.durationMs)
|
||||||
|
CarToasts.message.value?.let(CarToasts::dismiss)
|
||||||
CarToasts.show(owner, "Unknown", Int.MAX_VALUE)
|
CarToasts.show(owner, "Unknown", Int.MAX_VALUE)
|
||||||
assertEquals(4_000L, CarToasts.message.value?.durationMs)
|
assertEquals(4_000L, CarToasts.message.value?.durationMs)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user