mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
feat(desktop): preview-then-publish gate for image uploads
When the post has image attachments, the Publish button now reads
"Preview" instead. Clicking it runs ImageReencoder on every
attachment eagerly, then opens CompressionPreviewDialog with one
row per file:
- Reencoded rows: original thumbnail → compressed thumbnail +
dims/sizes/savings % + chip showing the active quality preset.
Click the row to open a side-by-side ZoomCompareDialog with
420 dp images and a "Saves N%" header.
- PassThrough rows: original thumbnail + "Animated / Vector ·
uploaded as-is" assist chip — covers animated GIF, animated
WebP, SVG, and the bypass-by-user path.
- Failed rows: original thumbnail + red-bordered surface +
"Could not compress: <reason>" + the privacy hint
("EXIF will be stripped" for JPEG, "metadata may still be
present" for non-JPEG). User can still publish — original
bytes ship.
- NonImage rows: filename + extension badge + "uploaded as-is"
for any non-image attachment caught up in the batch.
The dialog's Publish button calls the same runPublish lambda the
main button uses. The lambda walks the preview items and tells the
orchestrator either:
- preCompressed = <cached temp> for Reencoded,
- bypassReencode = true for Failed,
- default flags for PassThrough / NonImage.
UploadOrchestrator.upload gains a `preCompressed: File?` param
so the dialog can hand off ownership of the cached temp; the
orchestrator deletes it after the actual upload in the same
finally block.
Cancel cleans up every cached temp via cleanupPreviewTemps so a
dismissed preview doesn't leak.
The standalone CompressionFailureDialog from Phase 7 is now
unreachable (all failures surface inline in the preview), so it
gets deleted. The shared `runPublish` lambda was hoisted out of
the Card into the composable's top scope so both the main button
and the preview's onPublish callback can call it.
Triggered by the user's manual-testing feedback: "shouldn't I
preview the compressed images before publishing the note?" — the
plan's deferred compare dialog became the natural publish gate.
This commit is contained in:
+152
-149
@@ -64,9 +64,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.CompressionException
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.ImageFormatSniffer
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.UploadResult
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||
@@ -78,11 +76,13 @@ import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.compose.ComposeRelayPicker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.compose.RelayPickerState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.ClipboardPasteHandler
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.CompressionFailureDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.CompressionPreviewDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.FailedAttachment
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.PreviewItem
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.QualitySelectorChip
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.buildPreview
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.cleanupPreviewTemps
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
@@ -98,7 +98,6 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quote
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -187,9 +186,16 @@ fun ComposeNoteDialog(
|
||||
var perPostQualityOverride by remember { mutableStateOf<CompressionQuality?>(null) }
|
||||
val activeQuality = perPostQualityOverride ?: defaultQuality
|
||||
|
||||
// Fail-loud failure dialog state. CompletableDeferred suspends the
|
||||
// send loop until the user confirms or cancels.
|
||||
var pendingFailureDialog by remember { mutableStateOf<FailureDialogState?>(null) }
|
||||
// Preview-then-publish state. When images are attached, the
|
||||
// publish button reads "Preview" and a click here triggers the
|
||||
// CompressionPreviewDialog: every attachment is run through the
|
||||
// ImageReencoder eagerly, the user reviews the result, and the
|
||||
// actual upload uses the cached temp files (preCompressed).
|
||||
var pendingPreview by remember { mutableStateOf<List<PreviewItem>?>(null) }
|
||||
val hasImages =
|
||||
attachedFiles.any {
|
||||
it.extension.lowercase() in IMAGE_EXTENSIONS
|
||||
}
|
||||
|
||||
// Relay picker state
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
@@ -231,6 +237,109 @@ fun ComposeNoteDialog(
|
||||
}
|
||||
}
|
||||
|
||||
// Shared upload + publish flow. Called by either the main button
|
||||
// (when there are no images, hence no preview gate) or by the
|
||||
// CompressionPreviewDialog's Publish button (when the user has
|
||||
// confirmed the preview).
|
||||
val runPublish: () -> Unit = {
|
||||
scope.launch {
|
||||
isPosting = true
|
||||
errorMessage = null
|
||||
try {
|
||||
val preview = pendingPreview
|
||||
pendingPreview = null
|
||||
val uploadResults = mutableListOf<UploadResult>()
|
||||
|
||||
val total = preview?.size ?: attachedFiles.size
|
||||
val sources: List<Pair<File, PreviewItem?>> =
|
||||
preview?.map { it.source to it }
|
||||
?: attachedFiles.map { it to null }
|
||||
|
||||
for ((idx, pair) in sources.withIndex()) {
|
||||
val (file, item) = pair
|
||||
val n = idx + 1
|
||||
val prefix = if (total > 1) "$n/$total: " else ""
|
||||
uploadTracker.startUpload("$prefix${file.name}")
|
||||
val result =
|
||||
when (item) {
|
||||
is PreviewItem.Reencoded ->
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = null,
|
||||
serverBaseUrl = selectedServer,
|
||||
signer = account.signer,
|
||||
stripExif = stripExifSetting,
|
||||
quality = activeQuality,
|
||||
preCompressed = item.compressedFile,
|
||||
)
|
||||
is PreviewItem.Failed ->
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = null,
|
||||
serverBaseUrl = selectedServer,
|
||||
signer = account.signer,
|
||||
stripExif = stripExifSetting,
|
||||
quality = activeQuality,
|
||||
bypassReencode = true,
|
||||
)
|
||||
else ->
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = null,
|
||||
serverBaseUrl = selectedServer,
|
||||
signer = account.signer,
|
||||
stripExif = stripExifSetting,
|
||||
quality = activeQuality,
|
||||
)
|
||||
}
|
||||
uploadTracker.onSuccess(result)
|
||||
uploadResults.add(result)
|
||||
}
|
||||
|
||||
perPostQualityOverride = null
|
||||
|
||||
val finalContent =
|
||||
buildString {
|
||||
append(content)
|
||||
for (result in uploadResults) {
|
||||
result.blossom.url?.let { url ->
|
||||
if (isNotBlank()) append("\n")
|
||||
append(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postAsPicture) {
|
||||
val pictureMetas = buildPictureMetas(uploadResults)
|
||||
publishPicture(
|
||||
description = content,
|
||||
images = pictureMetas,
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
relays = selectedRelays,
|
||||
)
|
||||
} else {
|
||||
val imetaTags = buildIMetaTags(uploadResults)
|
||||
publishNote(
|
||||
content = finalContent,
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
replyTo = replyTo,
|
||||
quoteOf = quoteOf,
|
||||
imetaTags = imetaTags,
|
||||
relays = selectedRelays,
|
||||
)
|
||||
}
|
||||
onDismiss()
|
||||
} catch (e: Exception) {
|
||||
errorMessage = "Failed: ${e.message}"
|
||||
uploadTracker.onError(e.message ?: "Unknown error")
|
||||
} finally {
|
||||
isPosting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { if (!isPosting) onDismiss() },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
@@ -349,10 +458,6 @@ fun ComposeNoteDialog(
|
||||
// Server selector + per-post quality + post type — shown when files are attached
|
||||
if (attachedFiles.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val hasImages =
|
||||
attachedFiles.any {
|
||||
it.extension.lowercase() in IMAGE_EXTENSIONS
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -451,160 +556,58 @@ fun ComposeNoteDialog(
|
||||
errorMessage = "Note cannot be empty"
|
||||
return@Button
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
isPosting = true
|
||||
errorMessage = null
|
||||
|
||||
try {
|
||||
// Upload attached files and collect results.
|
||||
// Inline "Processing N/M" progress via the tracker's
|
||||
// existing fileName slot — no new state class needed.
|
||||
// CompressionException failures are collected and
|
||||
// surfaced in a fail-loud dialog before the post
|
||||
// publishes — never silently downgrade.
|
||||
val uploadResults = mutableListOf<UploadResult>()
|
||||
val failures = mutableListOf<FailedAttachment>()
|
||||
for ((idx, file) in attachedFiles.withIndex()) {
|
||||
val n = idx + 1
|
||||
val total = attachedFiles.size
|
||||
val prefix = if (total > 1) "$n/$total: " else ""
|
||||
uploadTracker.startUpload("$prefix${file.name}")
|
||||
try {
|
||||
val result =
|
||||
orchestrator.upload(
|
||||
file = file,
|
||||
alt = null,
|
||||
serverBaseUrl = selectedServer,
|
||||
signer = account.signer,
|
||||
stripExif = stripExifSetting,
|
||||
quality = activeQuality,
|
||||
)
|
||||
uploadTracker.onSuccess(result)
|
||||
uploadResults.add(result)
|
||||
} catch (e: CompressionException) {
|
||||
failures.add(
|
||||
FailedAttachment(
|
||||
file = file,
|
||||
exception = e,
|
||||
originalBytes = file.length(),
|
||||
sourceFormat = ImageFormatSniffer.sniff(file),
|
||||
),
|
||||
// When there are images and we don't have a
|
||||
// preview yet, the publish button acts as a
|
||||
// "Preview" gate — build the preview, show it,
|
||||
// and let the user confirm before any upload.
|
||||
if (hasImages && pendingPreview == null) {
|
||||
scope.launch {
|
||||
isPosting = true
|
||||
errorMessage = null
|
||||
try {
|
||||
pendingPreview =
|
||||
buildPreview(
|
||||
attachments = attachedFiles.toList(),
|
||||
imageExtensions = IMAGE_EXTENSIONS,
|
||||
quality = activeQuality,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
isPosting = false
|
||||
}
|
||||
|
||||
// If anything failed compression, ask the user
|
||||
// whether to ship raw bytes or abort. Block
|
||||
// until the dialog returns a UserChoice.
|
||||
if (failures.isNotEmpty()) {
|
||||
val choice = CompletableDeferred<FailureUserChoice>()
|
||||
pendingFailureDialog = FailureDialogState(failures.toList(), choice)
|
||||
val outcome = choice.await()
|
||||
pendingFailureDialog = null
|
||||
when (outcome) {
|
||||
FailureUserChoice.Cancel -> {
|
||||
isPosting = false
|
||||
return@launch
|
||||
}
|
||||
FailureUserChoice.SendOriginal -> {
|
||||
for (failure in failures) {
|
||||
uploadTracker.startUpload(failure.file.name)
|
||||
val result =
|
||||
orchestrator.upload(
|
||||
file = failure.file,
|
||||
alt = null,
|
||||
serverBaseUrl = selectedServer,
|
||||
signer = account.signer,
|
||||
stripExif = stripExifSetting,
|
||||
quality = activeQuality,
|
||||
bypassReencode = true,
|
||||
)
|
||||
uploadTracker.onSuccess(result)
|
||||
uploadResults.add(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset per-post override so the next post starts
|
||||
// from the saved default again.
|
||||
perPostQualityOverride = null
|
||||
|
||||
// Append uploaded URLs to content
|
||||
val finalContent =
|
||||
buildString {
|
||||
append(content)
|
||||
for (result in uploadResults) {
|
||||
result.blossom.url?.let { url ->
|
||||
if (isNotBlank()) append("\n")
|
||||
append(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (postAsPicture) {
|
||||
val pictureMetas = buildPictureMetas(uploadResults)
|
||||
publishPicture(
|
||||
description = content,
|
||||
images = pictureMetas,
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
relays = selectedRelays,
|
||||
)
|
||||
} else {
|
||||
val imetaTags = buildIMetaTags(uploadResults)
|
||||
publishNote(
|
||||
content = finalContent,
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
replyTo = replyTo,
|
||||
quoteOf = quoteOf,
|
||||
imetaTags = imetaTags,
|
||||
relays = selectedRelays,
|
||||
)
|
||||
}
|
||||
onDismiss()
|
||||
} catch (e: Exception) {
|
||||
errorMessage = "Failed: ${e.message}"
|
||||
uploadTracker.onError(e.message ?: "Unknown error")
|
||||
} finally {
|
||||
isPosting = false
|
||||
}
|
||||
return@Button
|
||||
}
|
||||
runPublish()
|
||||
},
|
||||
enabled = !isPosting && (content.isNotBlank() || attachedFiles.isNotEmpty()),
|
||||
) {
|
||||
Text(if (isPosting) "Publishing..." else "Publish")
|
||||
Text(
|
||||
when {
|
||||
isPosting -> if (pendingPreview == null && hasImages) "Compressing…" else "Publishing…"
|
||||
hasImages && pendingPreview == null -> "Preview"
|
||||
else -> "Publish"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingFailureDialog?.let { state ->
|
||||
CompressionFailureDialog(
|
||||
failures = state.failures,
|
||||
stripExifOnByPass = stripExifSetting,
|
||||
onSendOriginal = { state.choice.complete(FailureUserChoice.SendOriginal) },
|
||||
onCancel = { state.choice.complete(FailureUserChoice.Cancel) },
|
||||
pendingPreview?.let { items ->
|
||||
CompressionPreviewDialog(
|
||||
items = items,
|
||||
stripExifSetting = stripExifSetting,
|
||||
onPublish = runPublish,
|
||||
onCancel = {
|
||||
cleanupPreviewTemps(items)
|
||||
pendingPreview = null
|
||||
isPosting = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the in-flight failure dialog. Held in
|
||||
* [ComposeNoteDialog]'s `mutableStateOf` so the dialog renders on
|
||||
* top of the compose dialog while the send coroutine awaits the
|
||||
* user's choice via [choice].
|
||||
*/
|
||||
private data class FailureDialogState(
|
||||
val failures: List<FailedAttachment>,
|
||||
val choice: CompletableDeferred<FailureUserChoice>,
|
||||
)
|
||||
|
||||
private enum class FailureUserChoice { SendOriginal, Cancel }
|
||||
|
||||
private fun buildIMetaTags(results: List<UploadResult>): List<IMetaTag> =
|
||||
results.mapNotNull { result ->
|
||||
val url = result.blossom.url ?: return@mapNotNull null
|
||||
|
||||
-193
@@ -1,193 +0,0 @@
|
||||
/*
|
||||
* 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.desktop.ui.media
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.CompressionException
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.ImageFormat
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Captured failure for an attachment that the reencoder refused or
|
||||
* could not process. The dialog renders one row per failure.
|
||||
*/
|
||||
data class FailedAttachment(
|
||||
val file: File,
|
||||
val exception: CompressionException,
|
||||
val originalBytes: Long,
|
||||
val sourceFormat: ImageFormat,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fail-loud dialog: never silently downgrade. When one or more
|
||||
* attachments could not be compressed, the dialog shows the file
|
||||
* names + reasons + original byte counts and lets the user choose
|
||||
* between "Send Original" (upload raw bytes — still EXIF-stripped
|
||||
* for JPEG when the setting is on) and "Cancel post."
|
||||
*
|
||||
* Built as `Dialog { Surface }` rather than `AlertDialog` because:
|
||||
* - The body is a LazyColumn that grows with N failures; AlertDialog's
|
||||
* `text` slot is a single column with fixed width constraints.
|
||||
* - `LaunchedEffect` and `produceState` don't fire inside
|
||||
* AlertDialog's `text` slot (see `custom-feeds-alertdialog.md`),
|
||||
* leaving a path for future per-row actions (e.g., per-file
|
||||
* retry) that AlertDialog would block.
|
||||
*/
|
||||
@Composable
|
||||
fun CompressionFailureDialog(
|
||||
failures: List<FailedAttachment>,
|
||||
stripExifOnByPass: Boolean,
|
||||
onSendOriginal: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.width(560.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp)) {
|
||||
Text(
|
||||
if (failures.size == 1) {
|
||||
"1 image could not be compressed"
|
||||
} else {
|
||||
"${failures.size} images could not be compressed"
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"You can send the original file(s) without compression, " +
|
||||
"or cancel and try again.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp),
|
||||
) {
|
||||
items(failures, key = { it.file.canonicalPath }) { failure ->
|
||||
FailureRow(failure = failure, stripExifOnByPass = stripExifOnByPass)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(onClick = onCancel) {
|
||||
Text("Cancel post")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = onSendOriginal) {
|
||||
Text(
|
||||
if (failures.size == 1) {
|
||||
"Send original"
|
||||
} else {
|
||||
"Send originals (${failures.size})"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailureRow(
|
||||
failure: FailedAttachment,
|
||||
stripExifOnByPass: Boolean,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(
|
||||
failure.file.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"Could not compress: ${friendlyReason(failure.exception)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
buildString {
|
||||
append("Original: ").append(formatBytes(failure.originalBytes))
|
||||
append(" • ").append(privacyHint(failure, stripExifOnByPass))
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun friendlyReason(e: CompressionException): String =
|
||||
when (e) {
|
||||
is CompressionException.UnsupportedFormat -> "format not supported (${e.format})"
|
||||
is CompressionException.InputTooLarge -> "image is larger than ${e.limit / 1_000_000} megapixels"
|
||||
is CompressionException.EncodeFailed -> "encoder error (${e.cause?.message ?: e.message})"
|
||||
}
|
||||
|
||||
private fun privacyHint(
|
||||
failure: FailedAttachment,
|
||||
stripExifOnByPass: Boolean,
|
||||
): String {
|
||||
val isJpeg = failure.sourceFormat is ImageFormat.Jpeg
|
||||
return when {
|
||||
stripExifOnByPass && isJpeg -> "EXIF will be stripped before upload"
|
||||
stripExifOnByPass && !isJpeg -> "metadata may still be present (non-JPEG)"
|
||||
else -> "metadata preserved per your settings"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatBytes(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes B"
|
||||
if (bytes < 1024 * 1024) return "%.1f KB".format(bytes / 1024.0)
|
||||
return "%.1f MB".format(bytes / (1024.0 * 1024.0))
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* 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.desktop.ui.media
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.CompressionException
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.ImageFormat
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.ImageFormatSniffer
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.PassReason
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.ReencodeResult
|
||||
import java.io.File
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
/**
|
||||
* What the preview dialog renders for a single attachment.
|
||||
*
|
||||
* Built once per `Preview` click in the compose dialog; if the user
|
||||
* confirms via `Publish`, the items flow into the upload loop and the
|
||||
* orchestrator takes ownership of the cached temp files.
|
||||
*/
|
||||
sealed class PreviewItem {
|
||||
abstract val source: File
|
||||
abstract val originalSize: Long
|
||||
abstract val originalDims: Pair<Int, Int>?
|
||||
|
||||
/** A reencoded image — the dialog shows side-by-side stats + a click-to-zoom. */
|
||||
data class Reencoded(
|
||||
override val source: File,
|
||||
override val originalSize: Long,
|
||||
override val originalDims: Pair<Int, Int>?,
|
||||
val compressedFile: File,
|
||||
val compressedSize: Long,
|
||||
val compressedDims: Pair<Int, Int>,
|
||||
val quality: CompressionQuality,
|
||||
) : PreviewItem() {
|
||||
val savings: Double get() = 1.0 - (compressedSize.toDouble() / originalSize.toDouble())
|
||||
}
|
||||
|
||||
/** ImageReencoder said pass-through (animated GIF/WebP, SVG, BypassByUser). */
|
||||
data class PassThrough(
|
||||
override val source: File,
|
||||
override val originalSize: Long,
|
||||
override val originalDims: Pair<Int, Int>?,
|
||||
val reason: PassReason,
|
||||
) : PreviewItem()
|
||||
|
||||
/** Reencode threw — user can still publish; original ships raw. */
|
||||
data class Failed(
|
||||
override val source: File,
|
||||
override val originalSize: Long,
|
||||
override val originalDims: Pair<Int, Int>?,
|
||||
val exception: CompressionException,
|
||||
val sourceFormat: ImageFormat,
|
||||
) : PreviewItem()
|
||||
|
||||
/** Non-image attachment — shown for context, uploaded straight through. */
|
||||
data class NonImage(
|
||||
override val source: File,
|
||||
override val originalSize: Long,
|
||||
) : PreviewItem() {
|
||||
override val originalDims: Pair<Int, Int>? = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads source dimensions from a file via ImageIO header (no decode).
|
||||
* Returns null on any failure — preview keeps rendering, just without
|
||||
* the dim string.
|
||||
*/
|
||||
internal fun readDimensions(file: File): Pair<Int, Int>? =
|
||||
try {
|
||||
ImageIO.createImageInputStream(file).use { iis ->
|
||||
iis ?: return null
|
||||
val readers = ImageIO.getImageReaders(iis)
|
||||
if (!readers.hasNext()) return null
|
||||
val reader = readers.next()
|
||||
reader.input = iis
|
||||
try {
|
||||
reader.getWidth(0) to reader.getHeight(0)
|
||||
} finally {
|
||||
reader.dispose()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the preview list for a batch of attachments. Runs ImageReencoder
|
||||
* eagerly so the dialog renders complete state once. Non-image files
|
||||
* are not sent through the reencoder.
|
||||
*
|
||||
* IMPORTANT: callers own the lifecycle of any [PreviewItem.Reencoded.compressedFile]
|
||||
* temps returned here. On Cancel: delete each. On Publish: hand off
|
||||
* to [com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator]
|
||||
* via its `preCompressed` parameter — the orchestrator cleans up.
|
||||
*/
|
||||
suspend fun buildPreview(
|
||||
attachments: List<File>,
|
||||
imageExtensions: Set<String>,
|
||||
quality: CompressionQuality,
|
||||
): List<PreviewItem> =
|
||||
attachments.map { file ->
|
||||
val originalSize = file.length()
|
||||
val isImage = file.extension.lowercase() in imageExtensions
|
||||
if (!isImage) {
|
||||
return@map PreviewItem.NonImage(file, originalSize)
|
||||
}
|
||||
val originalDims = readDimensions(file)
|
||||
try {
|
||||
when (val result = ImageReencoder.reencode(file, quality)) {
|
||||
is ReencodeResult.Reencoded -> {
|
||||
val compressedDims = readDimensions(result.file) ?: (0 to 0)
|
||||
PreviewItem.Reencoded(
|
||||
source = file,
|
||||
originalSize = originalSize,
|
||||
originalDims = originalDims,
|
||||
compressedFile = result.file,
|
||||
compressedSize = result.file.length(),
|
||||
compressedDims = compressedDims,
|
||||
quality = quality,
|
||||
)
|
||||
}
|
||||
is ReencodeResult.PassThrough ->
|
||||
PreviewItem.PassThrough(
|
||||
source = file,
|
||||
originalSize = originalSize,
|
||||
originalDims = originalDims,
|
||||
reason = result.reason,
|
||||
)
|
||||
}
|
||||
} catch (e: CompressionException) {
|
||||
PreviewItem.Failed(
|
||||
source = file,
|
||||
originalSize = originalSize,
|
||||
originalDims = originalDims,
|
||||
exception = e,
|
||||
sourceFormat = ImageFormatSniffer.sniff(file),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete any temp files held by Reencoded items. Called on Cancel. */
|
||||
fun cleanupPreviewTemps(items: List<PreviewItem>) {
|
||||
items.forEach { item ->
|
||||
if (item is PreviewItem.Reencoded) {
|
||||
item.compressedFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact byte formatter for the preview rows. */
|
||||
internal fun formatBytes(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes B"
|
||||
if (bytes < 1024 * 1024) return "%.1f KB".format(bytes / 1024.0)
|
||||
return "%.1f MB".format(bytes / (1024.0 * 1024.0))
|
||||
}
|
||||
|
||||
internal fun formatDims(dims: Pair<Int, Int>?): String = dims?.let { "${it.first}×${it.second}" } ?: "unknown"
|
||||
|
||||
internal fun friendlyFailReason(e: CompressionException): String =
|
||||
when (e) {
|
||||
is CompressionException.UnsupportedFormat -> "format not supported (${e.format})"
|
||||
is CompressionException.InputTooLarge -> "image is larger than ${e.limit / 1_000_000} megapixels"
|
||||
is CompressionException.EncodeFailed -> "encoder error (${e.cause?.message ?: e.message})"
|
||||
}
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
/*
|
||||
* 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.desktop.ui.media
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.PassReason
|
||||
|
||||
/**
|
||||
* Preview-then-publish dialog: every attachment gets a row showing
|
||||
* what will actually be uploaded. Re-encoded rows show original-vs-
|
||||
* compressed thumbnails with dim/size/savings stats; click a row
|
||||
* to open a side-by-side zoom. Pass-through and fail rows render
|
||||
* with a badge so the user knows what's being shipped untouched.
|
||||
*
|
||||
* Built with `Dialog { Surface }` (not AlertDialog) — see the
|
||||
* compose-expert review notes in the plan: AlertDialog cramps wide
|
||||
* content and the state-isolation trap prevents LaunchedEffect /
|
||||
* subscription patterns inside its `text` slot.
|
||||
*/
|
||||
@Composable
|
||||
fun CompressionPreviewDialog(
|
||||
items: List<PreviewItem>,
|
||||
stripExifSetting: Boolean,
|
||||
onPublish: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var zoomed by remember { mutableStateOf<PreviewItem.Reencoded?>(null) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.width(820.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp)) {
|
||||
Text(
|
||||
"Preview & publish",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
summaryLine(items),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items, key = { it.source.canonicalPath }) { item ->
|
||||
when (item) {
|
||||
is PreviewItem.Reencoded ->
|
||||
ReencodedRow(item = item, onZoom = { zoomed = item })
|
||||
is PreviewItem.PassThrough -> PassThroughRow(item = item)
|
||||
is PreviewItem.Failed -> FailedRow(item = item, stripExifSetting = stripExifSetting)
|
||||
is PreviewItem.NonImage -> NonImageRow(item = item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = onCancel) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = onPublish) {
|
||||
Text("Publish (${items.size})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
zoomed?.let { item ->
|
||||
ZoomCompareDialog(item = item, onDismiss = { zoomed = null })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- row composables ----------
|
||||
|
||||
@Composable
|
||||
private fun ReencodedRow(
|
||||
item: PreviewItem.Reencoded,
|
||||
onZoom: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onZoom() },
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Thumbnail(item.source)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("→", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Thumbnail(item.compressedFile)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.source.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
"${formatDims(item.originalDims)} → ${formatDims(item.compressedDims)} · ${item.quality.chipLabel}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"${formatBytes(item.originalSize)} → ${formatBytes(item.compressedSize)} · saves ${"%.0f".format(item.savings * 100)}%",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Click to compare",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PassThroughRow(item: PreviewItem.PassThrough) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Thumbnail(item.source)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.source.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
"${formatDims(item.originalDims)} · ${formatBytes(item.originalSize)} · uploaded as-is",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(passReasonBadge(item.reason), style = MaterialTheme.typography.labelSmall) },
|
||||
colors = AssistChipDefaults.assistChipColors(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailedRow(
|
||||
item: PreviewItem.Failed,
|
||||
stripExifSetting: Boolean,
|
||||
) {
|
||||
val isJpeg = item.sourceFormat is com.vitorpamplona.amethyst.commons.service.upload.ImageFormat.Jpeg
|
||||
val privacy =
|
||||
when {
|
||||
stripExifSetting && isJpeg -> "EXIF will be stripped before upload"
|
||||
stripExifSetting && !isJpeg -> "metadata may still be present (non-JPEG)"
|
||||
else -> "metadata preserved per your settings"
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.20f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Thumbnail(item.source)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.source.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
Text(
|
||||
"Could not compress: ${friendlyFailReason(item.exception)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
"Will send original (${formatBytes(item.originalSize)}) · $privacy",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NonImageRow(item: PreviewItem.NonImage) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
item.source.extension
|
||||
.uppercase()
|
||||
.take(4),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.source.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
Text(
|
||||
"${formatBytes(item.originalSize)} · uploaded as-is (non-image)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Thumbnail(file: java.io.File) {
|
||||
AsyncImage(
|
||||
model = file,
|
||||
contentDescription = file.name,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- zoom sub-dialog ----------
|
||||
|
||||
@Composable
|
||||
private fun ZoomCompareDialog(
|
||||
item: PreviewItem.Reencoded,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.width(960.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp)) {
|
||||
Text(
|
||||
item.source.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Saves ${"%.0f".format(item.savings * 100)}% (${formatBytes(item.originalSize)} → ${formatBytes(item.compressedSize)}) at ${item.quality.chipLabel}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ZoomSide(label = "Original", dims = item.originalDims, sizeBytes = item.originalSize, file = item.source)
|
||||
ZoomSide(label = "Compressed", dims = item.compressedDims, sizeBytes = item.compressedSize, file = item.compressedFile)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(onClick = onDismiss) { Text("Close") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun androidx.compose.foundation.layout.RowScope.ZoomSide(
|
||||
label: String,
|
||||
dims: Pair<Int, Int>?,
|
||||
sizeBytes: Long,
|
||||
file: java.io.File,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"${formatDims(dims)} · ${formatBytes(sizeBytes)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
AsyncImage(
|
||||
model = file,
|
||||
contentDescription = "$label: ${file.name}",
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(420.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private fun summaryLine(items: List<PreviewItem>): String {
|
||||
val reencoded = items.count { it is PreviewItem.Reencoded }
|
||||
val passthrough = items.count { it is PreviewItem.PassThrough }
|
||||
val failed = items.count { it is PreviewItem.Failed }
|
||||
val nonImage = items.count { it is PreviewItem.NonImage }
|
||||
val totalSaving =
|
||||
items
|
||||
.filterIsInstance<PreviewItem.Reencoded>()
|
||||
.sumOf { it.originalSize - it.compressedSize }
|
||||
val parts = mutableListOf<String>()
|
||||
if (reencoded > 0) parts.add("$reencoded reencoded (saves ${formatBytes(totalSaving)})")
|
||||
if (passthrough > 0) parts.add("$passthrough pass-through")
|
||||
if (failed > 0) parts.add("$failed couldn't be compressed")
|
||||
if (nonImage > 0) parts.add("$nonImage non-image")
|
||||
return parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun passReasonBadge(reason: PassReason): String =
|
||||
when (reason) {
|
||||
PassReason.Animated -> "Animated · uploaded as-is"
|
||||
PassReason.Vector -> "Vector · uploaded as-is"
|
||||
PassReason.BypassByUser -> "Original · uploaded as-is"
|
||||
}
|
||||
Reference in New Issue
Block a user