Merge pull request #546 from dmnyc/feat/compose-countdown-progress

feat(compose): countdown progress bar + auto-scroll preview
This commit is contained in:
Barry Deen
2026-05-20 10:18:29 -04:00
committed by GitHub
2 changed files with 85 additions and 16 deletions
@@ -23,7 +23,9 @@ import androidx.compose.foundation.content.contentReceiver
import androidx.compose.foundation.content.hasMediaType
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
@@ -96,6 +98,8 @@ 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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -103,9 +107,12 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import kotlinx.coroutines.delay
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
@@ -166,6 +173,8 @@ fun ComposeScreen(
val uploadedUrls by viewModel.uploadedUrls.collectAsState()
val uploadProgress by viewModel.uploadProgress.collectAsState()
val countdownSeconds by viewModel.countdownSeconds.collectAsState()
val countdownTotalSeconds by viewModel.countdownTotalSeconds.collectAsState()
val countdownStartedAt by viewModel.countdownStartedAt.collectAsState()
val mentionCandidates by viewModel.mentionCandidates.collectAsState()
val mentionQuery by viewModel.mentionQuery.collectAsState()
val explicit by viewModel.explicit.collectAsState()
@@ -197,6 +206,30 @@ fun ComposeScreen(
val imeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
// Countdown progress (smooth, ~60fps)
var countdownProgress by remember { mutableFloatStateOf(0f) }
LaunchedEffect(countdownStartedAt) {
if (countdownStartedAt == null) { countdownProgress = 0f; return@LaunchedEffect }
val totalMs = countdownTotalSeconds * 1000L
val startTime = countdownStartedAt!!
while (true) {
val elapsed = System.currentTimeMillis() - startTime
countdownProgress = (elapsed.toFloat() / totalMs).coerceIn(0f, 1f)
if (countdownProgress >= 1f) break
delay(16)
}
}
// Scroll preview to top of viewport when countdown active and keyboard gone
val scrollState = rememberScrollState()
var previewTopOffsetPx by remember { mutableIntStateOf(0) }
LaunchedEffect(imeVisible, countdownSeconds) {
if (!imeVisible && countdownSeconds != null) {
val showPreview = content.text.isNotBlank() || (pollEnabled && pollOptions.any { it.isNotBlank() })
if (showPreview) scrollState.animateScrollTo(previewTopOffsetPx)
}
}
val photoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia()
) { uris ->
@@ -324,7 +357,7 @@ fun ComposeScreen(
OutlinedTextField(
value = content,
onValueChange = { viewModel.updateContent(it) },
label = { Text(stringResource(R.string.compose_gallery_placeholder)) },
placeholder = { Text(stringResource(R.string.compose_gallery_placeholder)) },
enabled = !publishing && countdownSeconds == null,
visualTransformation = galleryEmojiVisual,
modifier = Modifier
@@ -525,7 +558,7 @@ fun ComposeScreen(
.weight(1f)
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState)
) {
// Reply context (expandable)
replyTo?.let {
@@ -722,7 +755,7 @@ fun ComposeScreen(
singleLine = false,
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
interactionSource = interactionSource,
label = { Text(stringResource(R.string.compose_placeholder)) }
placeholder = { Text(stringResource(R.string.compose_placeholder)) }
)
}
)
@@ -1082,6 +1115,11 @@ fun ComposeScreen(
}
}
// Anchor for scroll-to-preview (always in layout so position is always valid)
Spacer(modifier = Modifier.onGloballyPositioned { coords ->
previewTopOffsetPx = coords.positionInParent().y.toInt()
})
// Live preview
AnimatedVisibility(
visible = !imeVisible && (content.text.isNotBlank() || (pollEnabled && pollOptions.any { it.isNotBlank() })) && eventRepo != null
@@ -1181,26 +1219,45 @@ fun ComposeScreen(
}
// Bottom bar — always visible above keyboard (shared by both modes)
Column(modifier = Modifier.padding(horizontal = 16.dp).padding(top = 4.dp)) {
Column(modifier = Modifier.padding(horizontal = 16.dp).padding(vertical = 12.dp)) {
if (countdownSeconds != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(
onClick = { viewModel.cancelPublish() },
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
// Cancel — red circle with X
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(44.dp)
.background(Color(0xFFE53935), CircleShape)
.clickable { viewModel.cancelPublish() }
) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.btn_undo),
tint = Color.White,
modifier = Modifier.size(20.dp)
)
) {
Text(stringResource(R.string.btn_undo))
}
Spacer(Modifier.width(8.dp))
Button(
onClick = { viewModel.publishNow() },
modifier = Modifier.weight(1f)
// Progress bar pill — fills left-to-right over the undo window
Box(
modifier = Modifier
.weight(1f)
.height(44.dp)
.clip(CircleShape)
.clickable { viewModel.publishNow() }
) {
Text(stringResource(R.string.compose_post_now, countdownSeconds!!))
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.primary.copy(alpha = 0.25f)))
Box(modifier = Modifier.fillMaxHeight().fillMaxWidth(countdownProgress).background(MaterialTheme.colorScheme.primary))
Text(
text = stringResource(R.string.compose_post_now, countdownSeconds!!),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
} else {
@@ -1220,7 +1277,8 @@ fun ComposeScreen(
)
},
enabled = !publishing && !isMiningBusy,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth().height(44.dp),
contentPadding = PaddingValues(0.dp)
) {
Text(
when {
@@ -100,6 +100,12 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
private val _countdownSeconds = MutableStateFlow<Int?>(null)
val countdownSeconds: StateFlow<Int?> = _countdownSeconds
private val _countdownTotalSeconds = MutableStateFlow<Int>(10)
val countdownTotalSeconds: StateFlow<Int> = _countdownTotalSeconds
private val _countdownStartedAt = MutableStateFlow<Long?>(null)
val countdownStartedAt: StateFlow<Long?> = _countdownStartedAt
private val _mentionQuery = MutableStateFlow<String?>(null)
val mentionQuery: StateFlow<String?> = _mentionQuery
@@ -611,6 +617,8 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
}
}
_countdownSeconds.value = seconds
_countdownTotalSeconds.value = seconds
_countdownStartedAt.value = System.currentTimeMillis()
countdownJob = viewModelScope.launch {
for (i in (seconds - 1) downTo 1) {
delay(1000)
@@ -618,6 +626,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
}
delay(1000)
_countdownSeconds.value = null
_countdownStartedAt.value = null
pendingPublish?.invoke()
pendingPublish = null
}
@@ -628,6 +637,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
countdownJob = null
pendingPublish = null
_countdownSeconds.value = null
_countdownStartedAt.value = null
_publishing.value = false
}
@@ -635,6 +645,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
countdownJob?.cancel()
countdownJob = null
_countdownSeconds.value = null
_countdownStartedAt.value = null
pendingPublish?.invoke()
pendingPublish = null
}