feat: add image, link, schedule, and error animations in composer

This commit is contained in:
Paweł Orzech 2026-03-19 14:23:25 +01:00
parent 0713bd912e
commit 188c62f076
No known key found for this signature in database

View file

@ -3,6 +3,8 @@ package com.swoosh.microblog.ui.composer
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
@ -37,6 +39,8 @@ import coil.compose.AsyncImage
import com.swoosh.microblog.data.HashtagParser import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.PulsingPlaceholder
import com.swoosh.microblog.ui.preview.HtmlPreviewWebView import com.swoosh.microblog.ui.preview.HtmlPreviewWebView
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@ -274,107 +278,131 @@ fun ComposerScreen(
} }
// Image grid preview (multi-image) // Image grid preview (multi-image)
if (state.imageUris.isNotEmpty()) { AnimatedVisibility(
Spacer(modifier = Modifier.height(12.dp)) visible = state.imageUris.isNotEmpty(),
ImageGridPreview( enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
imageUris = state.imageUris, exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
onRemoveImage = viewModel::removeImage, ) {
onAddMore = { multiImagePickerLauncher.launch("image/*") } Column {
) Spacer(modifier = Modifier.height(12.dp))
ImageGridPreview(
imageUris = state.imageUris,
onRemoveImage = viewModel::removeImage,
onAddMore = { multiImagePickerLauncher.launch("image/*") }
)
// Alt text for the first/primary image // Alt text for the first/primary image
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
TextButton( TextButton(
onClick = { showAltTextDialog = true }, onClick = { showAltTextDialog = true },
contentPadding = PaddingValues(horizontal = 0.dp) contentPadding = PaddingValues(horizontal = 0.dp)
) { ) {
Text( Text(
text = if (state.imageAlt.isBlank()) "Add alt text" else "Edit alt text", text = if (state.imageAlt.isBlank()) "Add alt text" else "Edit alt text",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
// ALT badge when alt text is set // ALT badge when alt text is set
if (state.imageAlt.isNotBlank()) { if (state.imageAlt.isNotBlank()) {
Text( Text(
text = "ALT", text = "ALT",
modifier = Modifier modifier = Modifier
.background( .background(
MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.8f), MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.8f),
RoundedCornerShape(4.dp) RoundedCornerShape(4.dp)
) )
.padding(horizontal = 6.dp, vertical = 2.dp), .padding(horizontal = 6.dp, vertical = 2.dp),
color = MaterialTheme.colorScheme.inverseOnSurface, color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
}
} }
} }
// Link preview // Link preview
if (state.isLoadingLink) { AnimatedVisibility(
Spacer(modifier = Modifier.height(12.dp)) visible = state.isLoadingLink,
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) enter = fadeIn(SwooshMotion.quick()),
exit = fadeOut(SwooshMotion.quick())
) {
Column {
Spacer(modifier = Modifier.height(12.dp))
PulsingPlaceholder(height = 80.dp)
}
} }
if (state.linkPreview != null) { AnimatedVisibility(
Spacer(modifier = Modifier.height(12.dp)) visible = state.linkPreview != null && !state.isLoadingLink,
OutlinedCard(modifier = Modifier.fillMaxWidth()) { enter = slideInVertically(initialOffsetY = { it / 2 }, animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick()),
Column(modifier = Modifier.padding(12.dp)) { exit = fadeOut(SwooshMotion.quick())
Row( ) {
modifier = Modifier.fillMaxWidth(), Column {
horizontalArrangement = Arrangement.SpaceBetween, Spacer(modifier = Modifier.height(12.dp))
verticalAlignment = Alignment.CenterVertically OutlinedCard(modifier = Modifier.fillMaxWidth()) {
) { Column(modifier = Modifier.padding(12.dp)) {
Text( Row(
text = state.linkPreview!!.title ?: state.linkPreview!!.url, modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.titleSmall, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.weight(1f) verticalAlignment = Alignment.CenterVertically
) ) {
IconButton(onClick = viewModel::removeLinkPreview) { Text(
Icon(Icons.Default.Close, "Remove link", Modifier.size(18.dp)) text = state.linkPreview!!.title ?: state.linkPreview!!.url,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = viewModel::removeLinkPreview) {
Icon(Icons.Default.Close, "Remove link", Modifier.size(18.dp))
}
}
if (state.linkPreview!!.description != null) {
Text(
text = state.linkPreview!!.description!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
if (state.linkPreview!!.imageUrl != null) {
Spacer(modifier = Modifier.height(8.dp))
AsyncImage(
model = state.linkPreview!!.imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Crop
)
} }
}
if (state.linkPreview!!.description != null) {
Text(
text = state.linkPreview!!.description!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
if (state.linkPreview!!.imageUrl != null) {
Spacer(modifier = Modifier.height(8.dp))
AsyncImage(
model = state.linkPreview!!.imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Crop
)
} }
} }
} }
} }
// Scheduled time display // Scheduled time display
if (state.scheduledAt != null) { AnimatedVisibility(
Spacer(modifier = Modifier.height(12.dp)) visible = state.scheduledAt != null,
AssistChip( enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
onClick = { showDatePicker = true }, exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
label = { Text("Scheduled: ${state.scheduledAt}") }, ) {
leadingIcon = { Icon(Icons.Default.Schedule, null, Modifier.size(18.dp)) }, Column {
trailingIcon = { Spacer(modifier = Modifier.height(12.dp))
IconButton( AssistChip(
onClick = { viewModel.setScheduledDate(null) }, onClick = { showDatePicker = true },
modifier = Modifier.size(18.dp) label = { Text("Scheduled: ${state.scheduledAt}") },
) { leadingIcon = { Icon(Icons.Default.Schedule, null, Modifier.size(18.dp)) },
Icon(Icons.Default.Close, "Clear schedule", Modifier.size(14.dp)) trailingIcon = {
IconButton(
onClick = { viewModel.setScheduledDate(null) },
modifier = Modifier.size(18.dp)
) {
Icon(Icons.Default.Close, "Clear schedule", Modifier.size(14.dp))
}
} }
} )
) }
} }
// Feature toggle // Feature toggle
@ -404,13 +432,19 @@ fun ComposerScreen(
) )
} }
if (state.error != null) { AnimatedVisibility(
Spacer(modifier = Modifier.height(12.dp)) visible = state.error != null,
Text( enter = slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = SwooshMotion.snappy()) + fadeIn(SwooshMotion.quick()),
text = state.error!!, exit = fadeOut(SwooshMotion.quick())
color = MaterialTheme.colorScheme.error, ) {
style = MaterialTheme.typography.bodySmall Column {
) Spacer(modifier = Modifier.height(12.dp))
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))