Screen re-design

This commit is contained in:
davotoula
2026-05-07 18:43:27 +02:00
parent f6991bee15
commit c9f6762751
11 changed files with 737 additions and 137 deletions
@@ -29,8 +29,8 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.extractContentPreview
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
* Posts user-visible system notifications when a scheduled post completes
@@ -51,7 +51,7 @@ object ScheduledPostNotifier {
context = context,
notId = idFor(post.id),
title = stringRes(context, R.string.scheduled_posts_notification_sent_title),
body = previewOf(post),
body = extractContentPreview(post, 120),
)
}
@@ -61,7 +61,7 @@ object ScheduledPostNotifier {
error: String?,
) {
ensureChannel(context)
val snippet = previewOf(post)
val snippet = extractContentPreview(post, 120)
val body =
if (error.isNullOrBlank()) {
snippet
@@ -124,15 +124,6 @@ object ScheduledPostNotifier {
nm.createNotificationChannel(channel!!)
}
private fun previewOf(post: ScheduledPost): String =
runCatching {
Event
.fromJson(post.signedEventJson)
.content
.take(120)
.trim()
}.getOrDefault("")
// Distinct id per post so multiple completions don't collapse onto one row.
private fun idFor(postId: String): Int = SCHEDULED_POST_NOT_ID_BASE xor postId.hashCode()
}
@@ -81,6 +81,7 @@ fun SwipeToDeleteContainer(
fun SwipeToDeleteWithConfirmation(
modifier: Modifier = Modifier,
onDelete: () -> Unit,
confirmLabelRes: Int = R.string.request_deletion,
content: @Composable (RowScope.() -> Unit),
) {
val scope = rememberCoroutineScope()
@@ -103,6 +104,7 @@ fun SwipeToDeleteWithConfirmation(
onCancel = {
scope.launch { dismissState.reset() }
},
confirmLabelRes = confirmLabelRes,
)
},
enableDismissFromEndToStart = true,
@@ -156,6 +158,7 @@ fun ConfirmDeleteBackground(
dismissState: SwipeToDismissBoxState,
onConfirmDelete: () -> Unit,
onCancel: () -> Unit,
confirmLabelRes: Int = R.string.request_deletion,
) {
val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled
@@ -195,12 +198,12 @@ fun ConfirmDeleteBackground(
) {
Icon(
MaterialSymbols.Delete,
contentDescription = stringRes(id = R.string.request_deletion),
contentDescription = stringRes(id = confirmLabelRes),
tint = Color.White,
)
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
Text(
text = stringRes(id = R.string.request_deletion),
text = stringRes(id = confirmLabelRes),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
@file:Suppress("ktlint:standard:filename")
package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
private const val TAG = "ScheduledPostMedia"
sealed class MediaUrl {
abstract val url: String
data class Image(
override val url: String,
) : MediaUrl()
data class Video(
override val url: String,
) : MediaUrl()
}
/**
* Parse the signed-event JSON, run [block], and return its result. Returns null on
* any non-cancellation parse failure and logs a warning. The shared shape for
* [extractFirstMediaUrl], [extractEventId], and [extractContentPreview].
*/
private inline fun <T : Any> parseSignedEvent(
post: ScheduledPost,
caller: String,
block: (Event) -> T?,
): T? =
try {
block(Event.fromJson(post.signedEventJson))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG) { "$caller: failed to parse signed event for ${post.id}: ${e.message}" }
null
}
/**
* Returns the first media URL referenced via an imeta tag in the post's signed event,
* or null when there is no imeta tag or the JSON cannot be parsed.
*
* Mime starting with `video/` -> [MediaUrl.Video]; anything else (including absent mime)
* -> [MediaUrl.Image]. Lenient on purpose: most posts attach images and don't always
* carry an `m` property.
*/
fun extractFirstMediaUrl(post: ScheduledPost): MediaUrl? =
parseSignedEvent(post, "extractFirstMediaUrl") { event ->
val firstImeta = event.imetas().firstOrNull() ?: return@parseSignedEvent null
val mime = firstImeta.properties["m"]?.firstOrNull().orEmpty()
when {
mime.startsWith("video/") -> MediaUrl.Video(firstImeta.url)
else -> MediaUrl.Image(firstImeta.url)
}
}
/**
* Returns the signed event's id, or null when the JSON cannot be parsed.
*/
fun extractEventId(post: ScheduledPost): String? = parseSignedEvent(post, "extractEventId") { it.id }
/**
* Returns the first [maxLen] chars of the signed event's content (trimmed) or
* an empty string when the JSON cannot be parsed.
*/
fun extractContentPreview(
post: ScheduledPost,
maxLen: Int,
): String =
parseSignedEvent(post, "extractContentPreview") { event ->
event.content.take(maxLen).trim()
} ?: ""
@Composable
fun MediaThumbnail(media: MediaUrl) {
Box(
modifier =
Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = media.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(64.dp),
)
if (media is MediaUrl.Video) {
Icon(
symbol = MaterialSymbols.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
)
}
}
}
@@ -20,32 +20,48 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts
import android.widget.Toast
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -53,10 +69,24 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -67,19 +97,21 @@ import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.coroutines.delay
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
@@ -93,9 +125,9 @@ fun ScheduledPostsScreen(
ScheduledPostsViewModel.create(accountPubkey)
}
val groups by viewModel.groupedPosts.collectAsStateWithLifecycle()
val totalActive by viewModel.totalActive.collectAsStateWithLifecycle()
val context = LocalContext.current
var pendingPublishId by remember { mutableStateOf<String?>(null) }
var expandedId by remember { mutableStateOf<String?>(null) }
// Tick once per minute so relative-time strings ("publishes in 2h 13m")
// refresh on a long-open list instead of being frozen at first composition.
@@ -106,10 +138,48 @@ fun ScheduledPostsScreen(
}
}
val dueSoonCount by remember {
derivedStateOf {
groups
.flatMap { it.posts }
.count { it.publishAtSec - nowSec in 1..URGENT_THRESHOLD_SEC }
}
}
val barHeight = if (totalActive > 0) 64.dp else TopBarSize
Scaffold(
topBar = {
ShorterTopAppBar(
title = { Text(stringRes(R.string.scheduled_posts)) },
expandedHeight = barHeight,
title = {
Column(modifier = Modifier.semantics(mergeDescendants = true) {}) {
Text(stringRes(R.string.scheduled_posts))
if (totalActive > 0) {
val queuedText =
pluralStringResource(
id = R.plurals.scheduled_posts_subtitle_queued,
count = totalActive,
totalActive,
)
val dueText =
if (dueSoonCount > 0) {
pluralStringResource(
id = R.plurals.scheduled_posts_subtitle_due_suffix,
count = dueSoonCount,
dueSoonCount,
)
} else {
""
}
Text(
text = queuedText + dueText,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() }
},
@@ -117,7 +187,7 @@ fun ScheduledPostsScreen(
},
) { padding ->
if (groups.isEmpty()) {
EmptyState(modifier = Modifier.padding(padding))
EmptyState(onCompose = { nav.nav(Route.NewShortNote()) }, modifier = Modifier.padding(padding))
} else {
val today = remember(nowSec) { LocalDate.now(ZoneId.systemDefault()) }
LazyColumn(
@@ -130,101 +200,274 @@ fun ScheduledPostsScreen(
DayHeader(group.day, today, context)
}
items(group.posts, key = { it.id }) { post ->
SwipeToDeleteWithConfirmation(
modifier = Modifier.fillMaxWidth().animateContentSize(),
onDelete = { viewModel.cancel(post.id) },
) {
ScheduledPostRow(
post = post,
nowSec = nowSec,
onPublishNow = { pendingPublishId = post.id },
)
val isExpanded = expandedId == post.id
val rowAlpha by animateFloatAsState(
targetValue = if (expandedId != null && !isExpanded) 0.65f else 1f,
label = "row-alpha",
)
Box(modifier = Modifier.alpha(rowAlpha)) {
if (isExpanded) {
Column(
modifier =
Modifier
.fillMaxWidth()
.animateContentSize(),
) {
ScheduledPostCardCollapsed(
post = post,
nowSec = nowSec,
onClick = { expandedId = null },
)
ScheduledPostCardExpandedPanel(
post = post,
onPublishNow = {
viewModel.publishNow(post.id)
ScheduledPostWorker.scheduleCatchUp(context)
expandedId = null
},
onDelete = {
viewModel.cancel(post.id)
expandedId = null
},
)
}
} else {
SwipeToDeleteWithConfirmation(
modifier = Modifier.fillMaxWidth().animateContentSize(),
onDelete = { viewModel.cancel(post.id) },
confirmLabelRes = R.string.scheduled_posts_action_delete,
) {
ScheduledPostCardCollapsed(
post = post,
nowSec = nowSec,
onClick = { expandedId = post.id },
)
}
}
}
}
}
}
}
}
}
pendingPublishId?.let { id ->
ConfirmDialog(
title = stringRes(R.string.scheduled_posts_send_now_title),
message = stringRes(R.string.scheduled_posts_send_now_message),
confirmLabel = stringRes(R.string.scheduled_posts_send_now_confirm),
onConfirm = {
viewModel.publishNow(id)
ScheduledPostWorker.scheduleCatchUp(context)
pendingPublishId = null
},
onDismiss = { pendingPublishId = null },
private const val URGENT_THRESHOLD_SEC = 3600L
// Tailwind amber-400. Material 3 has no amber slot, but the publishing-pulse
// reads better than `tertiary` against a violet card.
private val PublishingAmber = Color(0xFFFBBF24)
@Composable
private fun Modifier.urgentEdge(enabled: Boolean): Modifier {
if (!enabled) return this
val gradientStart = MaterialTheme.colorScheme.primary
val gradientEnd = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
val brush =
remember(gradientStart, gradientEnd) {
Brush.verticalGradient(listOf(gradientStart, gradientEnd))
}
return this.drawBehind {
drawRect(
brush = brush,
topLeft = Offset(0f, 8.dp.toPx()),
size = Size(3.dp.toPx(), size.height - 16.dp.toPx()),
)
}
}
@Composable
private fun ScheduledPostRow(
private fun ScheduledPostCardCollapsed(
post: ScheduledPost,
nowSec: Long,
onPublishNow: () -> Unit,
onClick: () -> Unit,
) {
val context = LocalContext.current
val preview = remember(post) { extractPreview(post) }
val preview = remember(post.id) { extractContentPreview(post, 200) }
val media = remember(post.id) { extractFirstMediaUrl(post) }
val relayCountText =
pluralStringResource(
id = R.plurals.scheduled_posts_relay_count,
count = post.relayUrls.size,
post.relayUrls.size,
)
val isFailed = post.status == ScheduledPostStatus.FAILED
// Composite the tint over surface so the card is opaque — otherwise the
// SwipeToDismissBox background ("Delete" / "Cancel") bleeds through at rest.
val surface = MaterialTheme.colorScheme.surface
val containerColor =
if (isFailed) {
MaterialTheme.colorScheme.error
.copy(alpha = 0.06f)
.compositeOver(surface)
} else {
MaterialTheme.colorScheme.primary
.copy(alpha = 0.06f)
.compositeOver(surface)
}
val borderColor =
if (isFailed) {
MaterialTheme.colorScheme.error.copy(alpha = 0.22f)
} else {
MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.outlinedCardColors(),
onClick = onClick,
modifier =
Modifier
.fillMaxWidth()
.urgentEdge(!isFailed && post.publishAtSec - nowSec in 1..URGENT_THRESHOLD_SEC),
colors = CardDefaults.cardColors(containerColor = containerColor),
border = BorderStroke(1.dp, borderColor),
shape = RoundedCornerShape(14.dp),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
StatusChip(post.status)
StatusPill(post.status)
Text(
text = formatAtTime(post.publishAtSec, nowSec, context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
}
Text(
text = preview,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
if (post.status == ScheduledPostStatus.FAILED && post.lastError != null) {
Text(
text = stringRes(R.string.scheduled_posts_error_prefix, post.lastError),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Row(
horizontalArrangement = Arrangement.End,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth(),
) {
IconButton(onClick = onPublishNow) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = stringRes(R.string.scheduled_posts_action_send_now),
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary,
)
if (media != null) {
MediaThumbnail(media)
}
Text(
text = preview,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = relayCountText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun ScheduledPostCardExpandedPanel(
post: ScheduledPost,
onPublishNow: () -> Unit,
onDelete: () -> Unit,
) {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val eventId = remember(post.id) { extractEventId(post) }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
HorizontalDivider(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f))
Column {
SectionLabel(stringRes(R.string.relays))
post.relayUrls.forEach { url ->
Text(
text = url,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f),
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
if (eventId != null) {
Column {
SectionLabel(stringRes(R.string.quick_action_copy_note_id))
Text(
text = eventId,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
modifier =
Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {},
onLongClick = {
clipboard.setText(AnnotatedString(eventId))
Toast
.makeText(
context,
stringRes(context, R.string.scheduled_posts_event_id_copied),
Toast.LENGTH_SHORT,
).show()
},
),
)
}
}
val err = post.lastError
if (post.status == ScheduledPostStatus.FAILED && !err.isNullOrBlank()) {
Text(
text = stringRes(R.string.scheduled_posts_error_prefix, err),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
Button(
onClick = onPublishNow,
enabled = post.status != ScheduledPostStatus.PUBLISHING,
modifier = Modifier.weight(1f),
) {
val labelRes =
when (post.status) {
ScheduledPostStatus.FAILED -> R.string.retry
ScheduledPostStatus.PUBLISHING -> R.string.scheduled_posts_status_publishing
else -> R.string.scheduled_posts_action_send_now
}
Text(stringRes(labelRes))
}
OutlinedButton(
onClick = onDelete,
modifier = Modifier.weight(1f),
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringRes(R.string.scheduled_posts_action_delete))
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text = text.uppercase(Locale.getDefault()),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp),
)
}
@Composable
private fun DayHeader(
day: LocalDate,
@@ -246,41 +489,101 @@ private fun DayHeader(
}
@Composable
private fun StatusChip(status: ScheduledPostStatus) {
val (labelRes, tint) =
private fun StatusPill(status: ScheduledPostStatus) {
val (labelRes, color, pulse) =
when (status) {
ScheduledPostStatus.PENDING -> R.string.scheduled_posts_status_pending to MaterialTheme.colorScheme.primary
ScheduledPostStatus.PUBLISHING -> R.string.scheduled_posts_status_publishing to MaterialTheme.colorScheme.tertiary
ScheduledPostStatus.FAILED -> R.string.scheduled_posts_status_failed to MaterialTheme.colorScheme.error
ScheduledPostStatus.SENT -> R.string.scheduled_posts_status_sent to MaterialTheme.colorScheme.tertiary
ScheduledPostStatus.CANCELLED -> R.string.scheduled_posts_status_cancelled to MaterialTheme.colorScheme.onSurfaceVariant
ScheduledPostStatus.PENDING -> {
Triple(R.string.scheduled_posts_status_pending, MaterialTheme.colorScheme.primary, false)
}
ScheduledPostStatus.PUBLISHING -> {
Triple(R.string.scheduled_posts_status_publishing, PublishingAmber, true)
}
ScheduledPostStatus.FAILED -> {
Triple(R.string.scheduled_posts_status_failed, MaterialTheme.colorScheme.error, false)
}
ScheduledPostStatus.SENT -> {
Triple(R.string.scheduled_posts_status_sent, MaterialTheme.colorScheme.tertiary, false)
}
ScheduledPostStatus.CANCELLED -> {
Triple(R.string.scheduled_posts_status_cancelled, MaterialTheme.colorScheme.onSurfaceVariant, false)
}
}
AssistChip(
onClick = {},
label = { Text(stringRes(labelRes), fontWeight = FontWeight.Medium) },
colors =
AssistChipDefaults.assistChipColors(
labelColor = tint,
),
)
val dotAlpha =
if (pulse) {
val transition = rememberInfiniteTransition(label = "publishing-pulse")
transition
.animateFloat(
initialValue = 1f,
targetValue = 0.35f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 1400, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "publishing-pulse-alpha",
).value
} else {
1f
}
Surface(
shape = RoundedCornerShape(50),
color = color.copy(alpha = 0.18f),
) {
Row(
modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(6.dp)
.clip(CircleShape)
.background(color.copy(alpha = dotAlpha)),
)
Spacer(Modifier.width(6.dp))
Text(
text = stringRes(labelRes),
color = color,
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp,
)
}
}
}
@Composable
private fun EmptyState(modifier: Modifier = Modifier) {
private fun EmptyState(
onCompose: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize().padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(
symbol = MaterialSymbols.Schedule,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box(
modifier =
Modifier
.size(56.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.Schedule,
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
Text(
text = stringRes(R.string.scheduled_posts_empty_title),
style = MaterialTheme.typography.titleMedium,
@@ -290,46 +593,13 @@ private fun EmptyState(modifier: Modifier = Modifier) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = onCompose) {
Text(stringRes(R.string.new_post))
}
}
}
}
@Composable
private fun ConfirmDialog(
title: String,
message: String,
confirmLabel: String,
destructive: Boolean = false,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
confirmLabel,
color = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
private fun extractPreview(post: ScheduledPost): String =
runCatching {
Event
.fromJson(post.signedEventJson)
.content
.take(200)
.trim()
}.getOrDefault("")
private val shortTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private val fullDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
@@ -76,6 +76,15 @@ class ScheduledPostsViewModel(
initialValue = emptyList(),
)
val totalActive: StateFlow<Int> =
groupedPosts
.map { groups -> groups.sumOf { it.posts.size } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = 0,
)
fun cancel(id: String) {
viewModelScope.launch(Dispatchers.IO) {
store.cancel(id)
@@ -2488,4 +2488,23 @@
<string name="scheduled_posts_notification_failed_title">Naplánovaný příspěvek selhal</string>
<string name="app_notification_scheduled_posts_channel_name">Naplánované příspěvky</string>
<string name="app_notification_scheduled_posts_channel_description">Oznámení, když je naplánovaný příspěvek publikován nebo selže při publikaci.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d ve frontě</item>
<item quantity="few">%d ve frontě</item>
<item quantity="many">%d ve frontě</item>
<item quantity="other">%d ve frontě</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 do 1 h</item>
<item quantity="few"> · %d do 1 h</item>
<item quantity="many"> · %d do 1 h</item>
<item quantity="other"> · %d do 1 h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">na 1 relay</item>
<item quantity="few">na %d relaye</item>
<item quantity="many">na %d relayů</item>
<item quantity="other">na %d relayů</item>
</plurals>
<string name="scheduled_posts_event_id_copied">ID příspěvku zkopírováno</string>
</resources>
@@ -2479,4 +2479,17 @@ anz der Bedingungen ist erforderlich</string>
<string name="scheduled_posts_notification_failed_title">Geplanter Beitrag fehlgeschlagen</string>
<string name="app_notification_scheduled_posts_channel_name">Geplante Beiträge</string>
<string name="app_notification_scheduled_posts_channel_description">Benachrichtigungen, wenn ein geplanter Beitrag veröffentlicht wird oder die Veröffentlichung fehlschlägt.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d in Warteschlange</item>
<item quantity="other">%d in Warteschlange</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 fällig in 1 Std.</item>
<item quantity="other"> · %d fällig in 1 Std.</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">an 1 Relay</item>
<item quantity="other">an %d Relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Beitrags-ID kopiert</string>
</resources>
@@ -2474,4 +2474,17 @@
<string name="scheduled_posts_notification_failed_title">Post agendado falhou</string>
<string name="app_notification_scheduled_posts_channel_name">Posts agendados</string>
<string name="app_notification_scheduled_posts_channel_description">Notificações quando um post agendado é publicado ou falha ao publicar.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d na fila</item>
<item quantity="other">%d na fila</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 em 1h</item>
<item quantity="other"> · %d em 1h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">para 1 relay</item>
<item quantity="other">para %d relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">ID do post copiado</string>
</resources>
@@ -2473,4 +2473,17 @@
<string name="scheduled_posts_notification_failed_title">Schemalagt inlägg misslyckades</string>
<string name="app_notification_scheduled_posts_channel_name">Schemalagda inlägg</string>
<string name="app_notification_scheduled_posts_channel_description">Aviseringar när ett schemalagt inlägg publiceras eller misslyckas att publicera.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d i kö</item>
<item quantity="other">%d i kö</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 inom 1 h</item>
<item quantity="other"> · %d inom 1 h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">till 1 relä</item>
<item quantity="other">till %d reläer</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Inläggs-ID kopierat</string>
</resources>
+13
View File
@@ -477,6 +477,19 @@
<string name="scheduled_posts_status_sent">Sent</string>
<string name="scheduled_posts_status_cancelled">Cancelled</string>
<string name="scheduled_posts_logout_warning">You have %1$d scheduled post(s) that haven\'t been published yet. Logging out will permanently delete them.</string>
<plurals name="scheduled_posts_subtitle_queued">
<item quantity="one">%d queued</item>
<item quantity="other">%d queued</item>
</plurals>
<plurals name="scheduled_posts_subtitle_due_suffix">
<item quantity="one"> · 1 due in 1h</item>
<item quantity="other"> · %d due in 1h</item>
</plurals>
<plurals name="scheduled_posts_relay_count">
<item quantity="one">to 1 relay</item>
<item quantity="other">to %d relays</item>
</plurals>
<string name="scheduled_posts_event_id_copied">Note ID copied</string>
<string name="polls">Polls</string>
<string name="open_polls">Open</string>
<string name="closed_polls">Closed</string>
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ScheduledPostMediaTest {
private fun postWithJson(json: String) =
ScheduledPost(
id = "id",
accountPubkey = "pk",
signedEventJson = json,
relayUrls = emptyList(),
extraEventsJson = emptyList(),
publishAtSec = 0,
createdAtSec = 0,
status = ScheduledPostStatus.PENDING,
)
@Test
fun extractFirstMediaUrl_returns_null_when_no_imeta() {
val json = """{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[],"content":"hi","sig":"x"}"""
assertNull(extractFirstMediaUrl(postWithJson(json)))
}
@Test
fun extractFirstMediaUrl_returns_image_when_mime_starts_with_image() {
val json =
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg","m image/jpeg"]],"content":"hi","sig":"x"}"""
val result = extractFirstMediaUrl(postWithJson(json))
assertTrue(result is MediaUrl.Image)
assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url)
}
@Test
fun extractFirstMediaUrl_returns_video_when_mime_starts_with_video() {
val json =
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/v.mp4","m video/mp4"]],"content":"hi","sig":"x"}"""
val result = extractFirstMediaUrl(postWithJson(json))
assertTrue(result is MediaUrl.Video)
assertEquals("https://x/v.mp4", (result as MediaUrl.Video).url)
}
@Test
fun extractFirstMediaUrl_defaults_to_image_when_no_mime() {
val json =
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg"]],"content":"hi","sig":"x"}"""
val result = extractFirstMediaUrl(postWithJson(json))
assertTrue(result is MediaUrl.Image)
assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url)
}
@Test
fun extractFirstMediaUrl_picks_first_when_multiple_imeta() {
val json =
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/a.jpg","m image/jpeg"],["imeta","url https://y/v.mp4","m video/mp4"]],"content":"hi","sig":"x"}"""
val result = extractFirstMediaUrl(postWithJson(json))
assertTrue(result is MediaUrl.Image)
assertEquals("https://x/a.jpg", (result as MediaUrl.Image).url)
}
@Test
fun extractFirstMediaUrl_returns_null_when_json_malformed() {
assertNull(extractFirstMediaUrl(postWithJson("{not json}")))
}
@Test
fun extractFirstMediaUrl_returns_null_when_imeta_has_no_url() {
val json =
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","m image/jpeg"]],"content":"hi","sig":"x"}"""
assertNull(extractFirstMediaUrl(postWithJson(json)))
}
@Test
fun extractFirstMediaUrl_picks_first_when_video_precedes_image() {
val json =
"""{"id":"a","pubkey":"p","kind":1,"created_at":0,"tags":[["imeta","url https://x/v.mp4","m video/mp4"],["imeta","url https://y/a.jpg","m image/jpeg"]],"content":"hi","sig":"x"}"""
val result = extractFirstMediaUrl(postWithJson(json))
assertTrue(result is MediaUrl.Video)
assertEquals("https://x/v.mp4", (result as MediaUrl.Video).url)
}
@Test
fun extractEventId_returns_id_for_well_formed_json() {
val json =
"""{"id":"abc123","pubkey":"p","kind":1,"created_at":0,"tags":[],"content":"hi","sig":"x"}"""
assertEquals("abc123", extractEventId(postWithJson(json)))
}
@Test
fun extractEventId_returns_null_when_json_malformed() {
assertNull(extractEventId(postWithJson("{not json}")))
}
}