feat: add counter, buttons, hashtag, and preview animations in composer

This commit is contained in:
Paweł Orzech 2026-03-19 14:25:16 +01:00
parent 4a7005ce1e
commit 15c678556e
No known key found for this signature in database

View file

@ -4,6 +4,8 @@ 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.*
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.* 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.*
@ -40,6 +42,7 @@ 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.animation.SwooshMotion
import kotlinx.coroutines.delay
import com.swoosh.microblog.ui.components.PulsingPlaceholder 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
@ -168,7 +171,12 @@ fun ComposerScreen(
} }
} }
if (state.isPreviewMode) { Crossfade(
targetState = state.isPreviewMode,
animationSpec = SwooshMotion.quick(),
label = "editPreviewCrossfade"
) { isPreview ->
if (isPreview) {
// Preview mode: show rendered HTML // Preview mode: show rendered HTML
if (state.text.isBlank() && state.imageUris.isEmpty() && state.linkPreview == null) { if (state.text.isBlank() && state.imageUris.isEmpty() && state.linkPreview == null) {
Box( Box(
@ -212,52 +220,63 @@ fun ComposerScreen(
supportingText = { supportingText = {
val charCount = state.text.length val charCount = state.text.length
val statsText = PostStats.formatComposerStats(state.text) val statsText = PostStats.formatComposerStats(state.text)
val color = when { val targetColor = when {
charCount > 500 -> MaterialTheme.colorScheme.error charCount > 500 -> MaterialTheme.colorScheme.error
charCount > 280 -> MaterialTheme.colorScheme.tertiary charCount > 280 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurfaceVariant else -> MaterialTheme.colorScheme.onSurfaceVariant
} }
val animatedColor by animateColorAsState(
targetValue = targetColor,
animationSpec = SwooshMotion.quick(),
label = "counterColor"
)
Text( Text(
text = statsText, text = statsText,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = color color = animatedColor
) )
} }
) )
// Extracted tags preview chips // Extracted tags preview chips
if (state.extractedTags.isNotEmpty()) { AnimatedVisibility(
Spacer(modifier = Modifier.height(8.dp)) visible = state.extractedTags.isNotEmpty(),
Row( enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
modifier = Modifier.fillMaxWidth(), exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
horizontalArrangement = Arrangement.spacedBy(6.dp) ) {
) { Column {
Icon( Spacer(modifier = Modifier.height(8.dp))
Icons.Default.Tag, Row(
contentDescription = "Tags", modifier = Modifier.fillMaxWidth(),
modifier = Modifier.size(18.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
state.extractedTags.forEach { tag -> Icon(
SuggestionChip( Icons.Default.Tag,
onClick = {}, contentDescription = "Tags",
label = { modifier = Modifier.size(18.dp),
Text( tint = MaterialTheme.colorScheme.onSurfaceVariant
"#$tag", )
style = MaterialTheme.typography.labelSmall @OptIn(ExperimentalLayoutApi::class)
) FlowRow(
}, horizontalArrangement = Arrangement.spacedBy(6.dp),
colors = SuggestionChipDefaults.suggestionChipColors( verticalArrangement = Arrangement.spacedBy(4.dp)
containerColor = MaterialTheme.colorScheme.primaryContainer, ) {
labelColor = MaterialTheme.colorScheme.onPrimaryContainer state.extractedTags.forEach { tag ->
), SuggestionChip(
border = null onClick = {},
) label = {
Text(
"#$tag",
style = MaterialTheme.typography.labelSmall
)
},
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
labelColor = MaterialTheme.colorScheme.onPrimaryContainer
),
border = null
)
}
} }
} }
} }
@ -452,43 +471,63 @@ fun ComposerScreen(
// Action buttons // Action buttons
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button( // Publish button (step 1)
onClick = viewModel::publish, var publishVisible by remember { mutableStateOf(false) }
modifier = Modifier.fillMaxWidth(), LaunchedEffect(Unit) { publishVisible = true }
enabled = !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty()) AnimatedVisibility(
visible = publishVisible,
enter = scaleIn(animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
) { ) {
if (state.isSubmitting) { Button(
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) onClick = viewModel::publish,
Spacer(Modifier.width(8.dp)) modifier = Modifier.fillMaxWidth(),
enabled = !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty())
) {
if (state.isSubmitting) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
}
Text(if (state.isEditing) "Update & Publish" else "Publish now")
} }
Text(if (state.isEditing) "Update & Publish" else "Publish now")
} }
Row( // Draft + Schedule row (step 2)
modifier = Modifier.fillMaxWidth(), var rowVisible by remember { mutableStateOf(false) }
horizontalArrangement = Arrangement.spacedBy(8.dp) LaunchedEffect(Unit) {
delay(SwooshMotion.StaggerDelayMs)
rowVisible = true
}
AnimatedVisibility(
visible = rowVisible,
enter = scaleIn(animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
) { ) {
OutlinedButton( Row(
onClick = viewModel::saveDraft, modifier = Modifier.fillMaxWidth(),
modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)
enabled = !state.isSubmitting
) { ) {
Text("Save draft") OutlinedButton(
} onClick = viewModel::saveDraft,
modifier = Modifier.weight(1f),
enabled = !state.isSubmitting
) {
Text("Save draft")
}
OutlinedButton( OutlinedButton(
onClick = { showDatePicker = true }, onClick = { showDatePicker = true },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
enabled = !state.isSubmitting enabled = !state.isSubmitting
) { ) {
Icon(Icons.Default.Schedule, null, Modifier.size(18.dp)) Icon(Icons.Default.Schedule, null, Modifier.size(18.dp))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("Schedule") Text("Schedule")
}
} }
} }
} }
} }
} }
} // end Crossfade
} }
} }