feat: Add @Preview composables for crop/trim upload UI elements

- Extract VideoTrimContent, ThumbnailFilmstrip, TrimTimeLabels,
  TrimApplyButton as standalone composables from VideoTrimDialog
- Add previews for DeleteButton, EditMediaButton, combined overlay
- Add previews for UploadingState (compressing, uploading states)
- Add previews for FilePreviewPlaceholder (audio, pdf)
- Add previews for TrimTimeLabels, TrimApplyButton (all states),
  ThumbnailFilmstrip loading, and full VideoTrimContent
- Add previews for UploadProgressIndicator (ready, uploading)
- All previews use ThemeComparisonColumn for dark/light comparison

https://claude.ai/code/session_01Ny6DPMzuAeQEgUpHcUrW6c
This commit is contained in:
Claude
2026-03-17 02:55:26 +00:00
parent e355560ec5
commit edf2f13d64
3 changed files with 340 additions and 136 deletions
@@ -64,6 +64,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.tooling.preview.Preview
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
@@ -77,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -352,3 +354,84 @@ fun FilePreviewPlaceholder(
}
}
}
// region Previews
@Preview
@Composable
fun DeleteButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.size(120.dp).background(MaterialTheme.colorScheme.surfaceVariant)) {
DeleteButton {}
}
}
}
@Preview
@Composable
fun EditMediaButtonPreview() {
ThemeComparisonColumn {
Box(Modifier.size(120.dp).background(MaterialTheme.colorScheme.surfaceVariant)) {
EditMediaButton {}
}
}
}
@Preview
@Composable
fun EditAndDeleteButtonsPreview() {
ThemeComparisonColumn {
Box(Modifier.size(120.dp).background(MaterialTheme.colorScheme.surfaceVariant)) {
DeleteButton {}
EditMediaButton {}
}
}
}
@Preview
@Composable
fun UploadingStateCompressingPreview() {
ThemeComparisonColumn {
Box(Modifier.size(120.dp).background(MaterialTheme.colorScheme.surfaceVariant)) {
UploadingState(0.3, UploadingState.Compressing)
}
}
}
@Preview
@Composable
fun UploadingStateUploadingPreview() {
ThemeComparisonColumn {
Box(Modifier.size(120.dp).background(MaterialTheme.colorScheme.surfaceVariant)) {
UploadingState(0.6, UploadingState.Uploading)
}
}
}
@Preview
@Composable
fun FilePreviewPlaceholderAudioPreview() {
ThemeComparisonColumn {
Box(Modifier.size(120.dp)) {
FilePreviewPlaceholder(
icon = Icons.Default.AudioFile,
label = "audio/mp3",
)
}
}
}
@Preview
@Composable
fun FilePreviewPlaceholderPdfPreview() {
ThemeComparisonColumn {
Box(Modifier.size(120.dp)) {
FilePreviewPlaceholder(
icon = Icons.Default.PictureAsPdf,
label = "application/pdf",
)
}
}
}
// endregion
@@ -37,12 +37,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Composable
fun UploadProgressIndicator(
@@ -99,3 +102,22 @@ fun UploadProgressIndicator(
}
}
}
@Preview
@Composable
fun UploadProgressIndicatorReadyPreview() {
ThemeComparisonColumn {
val orchestrator = UploadOrchestrator()
UploadProgressIndicator(orchestrator)
}
}
@Preview
@Composable
fun UploadProgressIndicatorUploadingPreview() {
ThemeComparisonColumn {
val orchestrator = UploadOrchestrator()
orchestrator.updateState(0.5, UploadingState.Uploading)
UploadProgressIndicator(orchestrator)
}
}
@@ -60,6 +60,7 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@@ -71,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -164,154 +166,251 @@ fun VideoTrimDialog(
)
},
) { padding ->
Column(
VideoTrimContent(
durationMs = durationMs,
rangeStart = rangeStart,
rangeEnd = rangeEnd,
thumbnails = thumbnails,
isTrimming = isTrimming,
onRangeChange = { start, end ->
rangeStart = start
rangeEnd = end
},
onApply = {
isTrimming = true
scope.launch {
val result =
withContext(Dispatchers.IO) {
VideoTrimmer.trim(
context,
videoUri,
rangeStart.toLong(),
rangeEnd.toLong(),
)
}
isTrimming = false
if (result != null) {
onTrimmed(result)
} else {
onCancel()
}
}
},
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VideoTrimContent(
durationMs: Float,
rangeStart: Float,
rangeEnd: Float,
thumbnails: List<Bitmap>,
isTrimming: Boolean,
onRangeChange: (Float, Float) -> Unit,
onApply: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (durationMs > 0f) {
ThumbnailFilmstrip(thumbnails)
RangeSlider(
value = rangeStart..rangeEnd,
onValueChange = { range ->
onRangeChange(range.start, range.endInclusive)
},
valueRange = 0f..durationMs,
modifier = Modifier.fillMaxWidth(),
)
TrimTimeLabels(rangeStart, rangeEnd)
Spacer(modifier = Modifier.weight(1f))
TrimApplyButton(isTrimming, rangeEnd > rangeStart, onApply)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (durationMs > 0f) {
// Thumbnail filmstrip
if (thumbnails.isNotEmpty()) {
LazyRow(
modifier =
Modifier
.fillMaxWidth()
.height(60.dp),
horizontalArrangement = Arrangement.spacedBy(1.dp),
) {
itemsIndexed(thumbnails) { _, bitmap ->
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier =
Modifier
.width(48.dp)
.height(60.dp),
)
}
}
} else {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(60.dp)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
// Range slider
RangeSlider(
value = rangeStart..rangeEnd,
onValueChange = { range ->
rangeStart = range.start
rangeEnd = range.endInclusive
},
valueRange = 0f..durationMs,
modifier = Modifier.fillMaxWidth(),
)
// Time labels
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(
text = stringRes(R.string.trim_start),
style = MaterialTheme.typography.bodySmall,
)
Text(
text = formatMsToTime(rangeStart.toLong()),
fontSize = 14.sp,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringRes(R.string.trim_duration),
style = MaterialTheme.typography.bodySmall,
)
Text(
text = formatMsToTime((rangeEnd - rangeStart).toLong()),
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = stringRes(R.string.trim_end),
style = MaterialTheme.typography.bodySmall,
)
Text(
text = formatMsToTime(rangeEnd.toLong()),
fontSize = 14.sp,
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Apply button
Button(
onClick = {
isTrimming = true
scope.launch {
val result =
withContext(Dispatchers.IO) {
VideoTrimmer.trim(
context,
videoUri,
rangeStart.toLong(),
rangeEnd.toLong(),
)
}
isTrimming = false
if (result != null) {
onTrimmed(result)
} else {
onCancel()
}
}
},
enabled = !isTrimming && rangeEnd > rangeStart,
modifier = Modifier.fillMaxWidth(),
) {
if (isTrimming) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp).width(20.dp),
strokeWidth = 2.dp,
)
Text(text = stringRes(R.string.video_trimming_in_progress))
}
} else {
Text(text = stringRes(R.string.apply_trim))
}
}
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
CircularProgressIndicator()
}
}
}
}
@Composable
fun ThumbnailFilmstrip(thumbnails: List<Bitmap>) {
if (thumbnails.isNotEmpty()) {
LazyRow(
modifier =
Modifier
.fillMaxWidth()
.height(60.dp),
horizontalArrangement = Arrangement.spacedBy(1.dp),
) {
itemsIndexed(thumbnails) { _, bitmap ->
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier =
Modifier
.width(48.dp)
.height(60.dp),
)
}
}
} else {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(60.dp)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
@Composable
fun TrimTimeLabels(
rangeStart: Float,
rangeEnd: Float,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(
text = stringRes(R.string.trim_start),
style = MaterialTheme.typography.bodySmall,
)
Text(
text = formatMsToTime(rangeStart.toLong()),
fontSize = 14.sp,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringRes(R.string.trim_duration),
style = MaterialTheme.typography.bodySmall,
)
Text(
text = formatMsToTime((rangeEnd - rangeStart).toLong()),
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = stringRes(R.string.trim_end),
style = MaterialTheme.typography.bodySmall,
)
Text(
text = formatMsToTime(rangeEnd.toLong()),
fontSize = 14.sp,
)
}
}
}
@Composable
fun TrimApplyButton(
isTrimming: Boolean,
isEnabled: Boolean,
onApply: () -> Unit,
) {
Button(
onClick = onApply,
enabled = !isTrimming && isEnabled,
modifier = Modifier.fillMaxWidth(),
) {
if (isTrimming) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp).width(20.dp),
strokeWidth = 2.dp,
)
Text(text = stringRes(R.string.video_trimming_in_progress))
}
} else {
Text(text = stringRes(R.string.apply_trim))
}
}
}
private fun formatMsToTime(ms: Long): String {
val totalSeconds = (ms / 1000).toInt()
return formatSecondsToTime(totalSeconds)
}
// region Previews
@Preview
@Composable
fun TrimTimeLabelsPreview() {
ThemeComparisonColumn {
TrimTimeLabels(
rangeStart = 5000f,
rangeEnd = 45000f,
)
}
}
@Preview
@Composable
fun TrimApplyButtonPreview() {
ThemeComparisonColumn {
Column {
TrimApplyButton(isTrimming = false, isEnabled = true, onApply = {})
TrimApplyButton(isTrimming = true, isEnabled = true, onApply = {})
TrimApplyButton(isTrimming = false, isEnabled = false, onApply = {})
}
}
}
@Preview
@Composable
fun ThumbnailFilmstripLoadingPreview() {
ThemeComparisonColumn {
ThumbnailFilmstrip(thumbnails = emptyList())
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun VideoTrimContentPreview() {
ThemeComparisonColumn {
VideoTrimContent(
durationMs = 60000f,
rangeStart = 5000f,
rangeEnd = 45000f,
thumbnails = emptyList(),
isTrimming = false,
onRangeChange = { _, _ -> },
onApply = {},
modifier = Modifier.height(400.dp).padding(16.dp),
)
}
}
// endregion