Screen re-design
This commit is contained in:
+3
-12
@@ -29,8 +29,8 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.extractContentPreview
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts user-visible system notifications when a scheduled post completes
|
* Posts user-visible system notifications when a scheduled post completes
|
||||||
@@ -51,7 +51,7 @@ object ScheduledPostNotifier {
|
|||||||
context = context,
|
context = context,
|
||||||
notId = idFor(post.id),
|
notId = idFor(post.id),
|
||||||
title = stringRes(context, R.string.scheduled_posts_notification_sent_title),
|
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?,
|
error: String?,
|
||||||
) {
|
) {
|
||||||
ensureChannel(context)
|
ensureChannel(context)
|
||||||
val snippet = previewOf(post)
|
val snippet = extractContentPreview(post, 120)
|
||||||
val body =
|
val body =
|
||||||
if (error.isNullOrBlank()) {
|
if (error.isNullOrBlank()) {
|
||||||
snippet
|
snippet
|
||||||
@@ -124,15 +124,6 @@ object ScheduledPostNotifier {
|
|||||||
nm.createNotificationChannel(channel!!)
|
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.
|
// 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()
|
private fun idFor(postId: String): Int = SCHEDULED_POST_NOT_ID_BASE xor postId.hashCode()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ fun SwipeToDeleteContainer(
|
|||||||
fun SwipeToDeleteWithConfirmation(
|
fun SwipeToDeleteWithConfirmation(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
|
confirmLabelRes: Int = R.string.request_deletion,
|
||||||
content: @Composable (RowScope.() -> Unit),
|
content: @Composable (RowScope.() -> Unit),
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -103,6 +104,7 @@ fun SwipeToDeleteWithConfirmation(
|
|||||||
onCancel = {
|
onCancel = {
|
||||||
scope.launch { dismissState.reset() }
|
scope.launch { dismissState.reset() }
|
||||||
},
|
},
|
||||||
|
confirmLabelRes = confirmLabelRes,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
enableDismissFromEndToStart = true,
|
enableDismissFromEndToStart = true,
|
||||||
@@ -156,6 +158,7 @@ fun ConfirmDeleteBackground(
|
|||||||
dismissState: SwipeToDismissBoxState,
|
dismissState: SwipeToDismissBoxState,
|
||||||
onConfirmDelete: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
|
confirmLabelRes: Int = R.string.request_deletion,
|
||||||
) {
|
) {
|
||||||
val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled
|
val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled
|
||||||
|
|
||||||
@@ -195,12 +198,12 @@ fun ConfirmDeleteBackground(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
MaterialSymbols.Delete,
|
MaterialSymbols.Delete,
|
||||||
contentDescription = stringRes(id = R.string.request_deletion),
|
contentDescription = stringRes(id = confirmLabelRes),
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
|
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringRes(id = R.string.request_deletion),
|
text = stringRes(id = confirmLabelRes),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
|
|||||||
+139
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+393
-123
@@ -20,32 +20,48 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.animation.animateContentSize
|
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.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.AssistChipDefaults
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
@@ -53,10 +69,24 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.vitorpamplona.amethyst.R
|
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.service.scheduledposts.ScheduledPostWorker
|
||||||
import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation
|
import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
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.ShorterTopAppBar
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize
|
||||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||||
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
|
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
|
||||||
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
|
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -93,9 +125,9 @@ fun ScheduledPostsScreen(
|
|||||||
ScheduledPostsViewModel.create(accountPubkey)
|
ScheduledPostsViewModel.create(accountPubkey)
|
||||||
}
|
}
|
||||||
val groups by viewModel.groupedPosts.collectAsStateWithLifecycle()
|
val groups by viewModel.groupedPosts.collectAsStateWithLifecycle()
|
||||||
|
val totalActive by viewModel.totalActive.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
var expandedId by remember { mutableStateOf<String?>(null) }
|
||||||
var pendingPublishId by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
// Tick once per minute so relative-time strings ("publishes in 2h 13m")
|
// 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.
|
// 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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
ShorterTopAppBar(
|
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 = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() }
|
IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() }
|
||||||
},
|
},
|
||||||
@@ -117,7 +187,7 @@ fun ScheduledPostsScreen(
|
|||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
if (groups.isEmpty()) {
|
if (groups.isEmpty()) {
|
||||||
EmptyState(modifier = Modifier.padding(padding))
|
EmptyState(onCompose = { nav.nav(Route.NewShortNote()) }, modifier = Modifier.padding(padding))
|
||||||
} else {
|
} else {
|
||||||
val today = remember(nowSec) { LocalDate.now(ZoneId.systemDefault()) }
|
val today = remember(nowSec) { LocalDate.now(ZoneId.systemDefault()) }
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -130,101 +200,274 @@ fun ScheduledPostsScreen(
|
|||||||
DayHeader(group.day, today, context)
|
DayHeader(group.day, today, context)
|
||||||
}
|
}
|
||||||
items(group.posts, key = { it.id }) { post ->
|
items(group.posts, key = { it.id }) { post ->
|
||||||
SwipeToDeleteWithConfirmation(
|
val isExpanded = expandedId == post.id
|
||||||
modifier = Modifier.fillMaxWidth().animateContentSize(),
|
val rowAlpha by animateFloatAsState(
|
||||||
onDelete = { viewModel.cancel(post.id) },
|
targetValue = if (expandedId != null && !isExpanded) 0.65f else 1f,
|
||||||
) {
|
label = "row-alpha",
|
||||||
ScheduledPostRow(
|
)
|
||||||
post = post,
|
Box(modifier = Modifier.alpha(rowAlpha)) {
|
||||||
nowSec = nowSec,
|
if (isExpanded) {
|
||||||
onPublishNow = { pendingPublishId = post.id },
|
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 ->
|
private const val URGENT_THRESHOLD_SEC = 3600L
|
||||||
ConfirmDialog(
|
|
||||||
title = stringRes(R.string.scheduled_posts_send_now_title),
|
// Tailwind amber-400. Material 3 has no amber slot, but the publishing-pulse
|
||||||
message = stringRes(R.string.scheduled_posts_send_now_message),
|
// reads better than `tertiary` against a violet card.
|
||||||
confirmLabel = stringRes(R.string.scheduled_posts_send_now_confirm),
|
private val PublishingAmber = Color(0xFFFBBF24)
|
||||||
onConfirm = {
|
|
||||||
viewModel.publishNow(id)
|
@Composable
|
||||||
ScheduledPostWorker.scheduleCatchUp(context)
|
private fun Modifier.urgentEdge(enabled: Boolean): Modifier {
|
||||||
pendingPublishId = null
|
if (!enabled) return this
|
||||||
},
|
val gradientStart = MaterialTheme.colorScheme.primary
|
||||||
onDismiss = { pendingPublishId = null },
|
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
|
@Composable
|
||||||
private fun ScheduledPostRow(
|
private fun ScheduledPostCardCollapsed(
|
||||||
post: ScheduledPost,
|
post: ScheduledPost,
|
||||||
nowSec: Long,
|
nowSec: Long,
|
||||||
onPublishNow: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
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(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onClick = onClick,
|
||||||
colors = CardDefaults.outlinedCardColors(),
|
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(
|
Column(
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
StatusChip(post.status)
|
StatusPill(post.status)
|
||||||
Text(
|
Text(
|
||||||
text = formatAtTime(post.publishAtSec, nowSec, context),
|
text = formatAtTime(post.publishAtSec, nowSec, context),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
Row(
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onPublishNow) {
|
if (media != null) {
|
||||||
Icon(
|
MediaThumbnail(media)
|
||||||
symbol = MaterialSymbols.AutoMirrored.Send,
|
|
||||||
contentDescription = stringRes(R.string.scheduled_posts_action_send_now),
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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
|
@Composable
|
||||||
private fun DayHeader(
|
private fun DayHeader(
|
||||||
day: LocalDate,
|
day: LocalDate,
|
||||||
@@ -246,41 +489,101 @@ private fun DayHeader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StatusChip(status: ScheduledPostStatus) {
|
private fun StatusPill(status: ScheduledPostStatus) {
|
||||||
val (labelRes, tint) =
|
val (labelRes, color, pulse) =
|
||||||
when (status) {
|
when (status) {
|
||||||
ScheduledPostStatus.PENDING -> R.string.scheduled_posts_status_pending to MaterialTheme.colorScheme.primary
|
ScheduledPostStatus.PENDING -> {
|
||||||
ScheduledPostStatus.PUBLISHING -> R.string.scheduled_posts_status_publishing to MaterialTheme.colorScheme.tertiary
|
Triple(R.string.scheduled_posts_status_pending, MaterialTheme.colorScheme.primary, false)
|
||||||
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.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(
|
val dotAlpha =
|
||||||
onClick = {},
|
if (pulse) {
|
||||||
label = { Text(stringRes(labelRes), fontWeight = FontWeight.Medium) },
|
val transition = rememberInfiniteTransition(label = "publishing-pulse")
|
||||||
colors =
|
transition
|
||||||
AssistChipDefaults.assistChipColors(
|
.animateFloat(
|
||||||
labelColor = tint,
|
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
|
@Composable
|
||||||
private fun EmptyState(modifier: Modifier = Modifier) {
|
private fun EmptyState(
|
||||||
|
onCompose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Box(
|
||||||
symbol = MaterialSymbols.Schedule,
|
modifier =
|
||||||
contentDescription = null,
|
Modifier
|
||||||
modifier = Modifier.size(48.dp),
|
.size(56.dp)
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
.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(
|
||||||
text = stringRes(R.string.scheduled_posts_empty_title),
|
text = stringRes(R.string.scheduled_posts_empty_title),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
@@ -290,46 +593,13 @@ private fun EmptyState(modifier: Modifier = Modifier) {
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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 shortTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||||
private val fullDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
|
private val fullDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
|
||||||
|
|
||||||
|
|||||||
+9
@@ -76,6 +76,15 @@ class ScheduledPostsViewModel(
|
|||||||
initialValue = emptyList(),
|
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) {
|
fun cancel(id: String) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
store.cancel(id)
|
store.cancel(id)
|
||||||
|
|||||||
@@ -2488,4 +2488,23 @@
|
|||||||
<string name="scheduled_posts_notification_failed_title">Naplánovaný příspěvek selhal</string>
|
<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_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>
|
<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>
|
</resources>
|
||||||
|
|||||||
@@ -2479,4 +2479,17 @@ anz der Bedingungen ist erforderlich</string>
|
|||||||
<string name="scheduled_posts_notification_failed_title">Geplanter Beitrag fehlgeschlagen</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_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>
|
<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>
|
</resources>
|
||||||
|
|||||||
@@ -2474,4 +2474,17 @@
|
|||||||
<string name="scheduled_posts_notification_failed_title">Post agendado falhou</string>
|
<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_name">Posts agendados</string>
|
||||||
<string name="app_notification_scheduled_posts_channel_description">Notificações quando um post agendado é publicado ou falha ao publicar.</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>
|
</resources>
|
||||||
|
|||||||
@@ -2473,4 +2473,17 @@
|
|||||||
<string name="scheduled_posts_notification_failed_title">Schemalagt inlägg misslyckades</string>
|
<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_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>
|
<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>
|
</resources>
|
||||||
|
|||||||
@@ -477,6 +477,19 @@
|
|||||||
<string name="scheduled_posts_status_sent">Sent</string>
|
<string name="scheduled_posts_status_sent">Sent</string>
|
||||||
<string name="scheduled_posts_status_cancelled">Cancelled</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>
|
<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="polls">Polls</string>
|
||||||
<string name="open_polls">Open</string>
|
<string name="open_polls">Open</string>
|
||||||
<string name="closed_polls">Closed</string>
|
<string name="closed_polls">Closed</string>
|
||||||
|
|||||||
+117
@@ -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}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user