feat(desktop): per-row skip toggle + explicit metadata-strip status in preview

Two manual-testing asks landed together — they share the same row
template inside CompressionPreviewDialog.

Per-row skip toggle:
  - Every preview row gains a Switch labeled "Include" / "Skipped"
    (the verb is shown so the user can't misread a bare switch).
  - Skipped rows dim the thumbnail (0.4 alpha) and tone down the
    surface, hide the "Click to compare" hint, and disable the
    click-to-zoom.
  - Publish button label now reflects the included count —
    "Publish (4)" when nothing skipped, "Publish (3 of 5)" with
    skips, "Nothing to publish" + disabled state when all skipped.
  - On Publish, the dialog calls cleanupPreviewTemps(skippedItems)
    so dropped re-encodes don't leak in ~/.amethyst/tmp/. The
    included subset is handed off to UploadOrchestrator via the
    preCompressed param as before.
  - onPublish signature changed: (List<PreviewItem>) -> Unit, and
    runPublish in ComposeNoteDialog now takes the filtered list
    rather than reading pendingPreview directly.

Explicit metadata-strip status on every row:
  - Reencoded rows: "All EXIF, GPS, camera tags stripped
    (re-encoded to JPEG)" in the tertiary color. Re-encode wipes
    metadata regardless of the strip-EXIF setting because we
    don't preserve any metadata in the JPEG writer.
  - PassThrough rows:
      Animated → "Metadata preserved (animated — re-encode would
                  drop frames)"
      Vector   → "No raster metadata (SVG)"
      Bypass   → "Metadata preserved per your override"
  - Failed rows (going to send original):
      JPEG + strip on → "EXIF, GPS, camera tags stripped before
                         upload" in tertiary color
      non-JPEG + strip on → "Metadata preserved — strip only runs
                             on JPEG; this is <Format>" in error
                             color (privacy warning)
      strip off → "Metadata preserved (EXIF strip off in settings)"
  - NonImage rows: "Metadata preserved — EXIF strip applies to
                    JPEG only"

The explicit per-row wording makes the strip-EXIF toggle's actual
behavior visible at the moment the user is deciding whether to
publish, rather than buried in the Settings panel.
This commit is contained in:
nrobi144
2026-06-09 11:42:01 +03:00
parent 0f5eae4906
commit b73f7fe4e2
2 changed files with 181 additions and 36 deletions
@@ -240,19 +240,21 @@ 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 = {
// confirmed the preview). The parameter is the filtered list of
// items the user actually wants to upload (skipped rows have
// already been cleaned up by the dialog), or null for the
// no-preview path.
val runPublish: (List<PreviewItem>?) -> Unit = { selectedItems ->
scope.launch {
isPosting = true
errorMessage = null
try {
val preview = pendingPreview
pendingPreview = null
val uploadResults = mutableListOf<UploadResult>()
val total = preview?.size ?: attachedFiles.size
val total = selectedItems?.size ?: attachedFiles.size
val sources: List<Pair<File, PreviewItem?>> =
preview?.map { it.source to it }
selectedItems?.map { it.source to it }
?: attachedFiles.map { it to null }
for ((idx, pair) in sources.withIndex()) {
@@ -577,7 +579,7 @@ fun ComposeNoteDialog(
}
return@Button
}
runPublish()
runPublish(null)
},
enabled = !isPosting && (content.isNotBlank() || attachedFiles.isNotEmpty()),
) {
@@ -598,7 +600,7 @@ fun ComposeNoteDialog(
CompressionPreviewDialog(
items = items,
stripExifSetting = stripExifSetting,
onPublish = runPublish,
onPublish = { included -> runPublish(included) },
onCancel = {
cleanupPreviewTemps(items)
pendingPreview = null
@@ -42,14 +42,18 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
@@ -75,11 +79,24 @@ import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.PassReas
fun CompressionPreviewDialog(
items: List<PreviewItem>,
stripExifSetting: Boolean,
onPublish: () -> Unit,
onPublish: (toUpload: List<PreviewItem>) -> Unit,
onCancel: () -> Unit,
) {
var zoomed by remember { mutableStateOf<PreviewItem.Reencoded?>(null) }
// Per-row skip state — keyed by canonical path so re-ordering or
// re-renders don't lose the user's choices.
val skipped = remember { mutableStateMapOf<String, Boolean>() }
fun isSkipped(item: PreviewItem) = skipped[item.source.canonicalPath] == true
fun toggleSkip(item: PreviewItem) {
skipped[item.source.canonicalPath] = !isSkipped(item)
}
val included = items.filterNot(::isSkipped)
val skippedItems = items.filter(::isSkipped)
Dialog(
onDismissRequest = onCancel,
properties = DialogProperties(usePlatformDefaultWidth = false),
@@ -109,12 +126,34 @@ fun CompressionPreviewDialog(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items, key = { it.source.canonicalPath }) { item ->
val skip = isSkipped(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)
ReencodedRow(
item = item,
isSkipped = skip,
onToggleSkip = { toggleSkip(item) },
onZoom = { zoomed = item },
)
is PreviewItem.PassThrough ->
PassThroughRow(
item = item,
isSkipped = skip,
onToggleSkip = { toggleSkip(item) },
)
is PreviewItem.Failed ->
FailedRow(
item = item,
stripExifSetting = stripExifSetting,
isSkipped = skip,
onToggleSkip = { toggleSkip(item) },
)
is PreviewItem.NonImage ->
NonImageRow(
item = item,
isSkipped = skip,
onToggleSkip = { toggleSkip(item) },
)
}
}
}
@@ -129,8 +168,23 @@ fun CompressionPreviewDialog(
Text("Cancel")
}
Spacer(Modifier.width(8.dp))
Button(onClick = onPublish) {
Text("Publish (${items.size})")
Button(
onClick = {
// Drop the temps for items the user chose
// to skip; included items hand off to the
// orchestrator which owns their cleanup.
cleanupPreviewTemps(skippedItems)
onPublish(included)
},
enabled = included.isNotEmpty(),
) {
Text(
when {
included.isEmpty() -> "Nothing to publish"
included.size == items.size -> "Publish (${items.size})"
else -> "Publish (${included.size} of ${items.size})"
},
)
}
}
}
@@ -147,25 +201,27 @@ fun CompressionPreviewDialog(
@Composable
private fun ReencodedRow(
item: PreviewItem.Reencoded,
isSkipped: Boolean,
onToggleSkip: () -> Unit,
onZoom: () -> Unit,
) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.clickable { onZoom() },
.clickable(enabled = !isSkipped) { onZoom() },
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (isSkipped) 0.15f else 0.35f),
) {
Row(
modifier = Modifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Thumbnail(item.source)
Thumbnail(item.source, dimmed = isSkipped)
Spacer(Modifier.width(8.dp))
Text("", color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(8.dp))
Thumbnail(item.compressedFile)
Thumbnail(item.compressedFile, dimmed = isSkipped)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -186,28 +242,42 @@ private fun ReencodedRow(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"All EXIF, GPS, camera tags stripped (re-encoded to JPEG)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
Column(horizontalAlignment = Alignment.End) {
if (!isSkipped) {
Text(
"Click to compare",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
SkipToggle(isSkipped = isSkipped, onToggle = onToggleSkip)
}
Text(
"Click to compare",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun PassThroughRow(item: PreviewItem.PassThrough) {
private fun PassThroughRow(
item: PreviewItem.PassThrough,
isSkipped: Boolean,
onToggleSkip: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (isSkipped) 0.15f else 0.35f),
) {
Row(
modifier = Modifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Thumbnail(item.source)
Thumbnail(item.source, dimmed = isSkipped)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -223,12 +293,19 @@ private fun PassThroughRow(item: PreviewItem.PassThrough) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
passThroughMetadataHint(item.reason),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AssistChip(
onClick = {},
label = { Text(passReasonBadge(item.reason), style = MaterialTheme.typography.labelSmall) },
colors = AssistChipDefaults.assistChipColors(),
)
Spacer(Modifier.width(8.dp))
SkipToggle(isSkipped = isSkipped, onToggle = onToggleSkip)
}
}
}
@@ -237,24 +314,26 @@ private fun PassThroughRow(item: PreviewItem.PassThrough) {
private fun FailedRow(
item: PreviewItem.Failed,
stripExifSetting: Boolean,
isSkipped: Boolean,
onToggleSkip: () -> Unit,
) {
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"
stripExifSetting && isJpeg -> "EXIF, GPS, camera tags stripped before upload"
stripExifSetting && !isJpeg -> "Metadata preserved — strip only runs on JPEG; this is ${item.sourceFormat::class.simpleName}"
else -> "Metadata preserved (EXIF strip off in settings)"
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.20f),
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = if (isSkipped) 0.10f else 0.20f),
) {
Row(
modifier = Modifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Thumbnail(item.source)
Thumbnail(item.source, dimmed = isSkipped)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -270,21 +349,36 @@ private fun FailedRow(
color = MaterialTheme.colorScheme.error,
)
Text(
"Will send original (${formatBytes(item.originalSize)}) · $privacy",
"Will send original (${formatBytes(item.originalSize)})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
privacy,
style = MaterialTheme.typography.labelSmall,
color =
if (stripExifSetting && isJpeg) {
MaterialTheme.colorScheme.tertiary
} else {
MaterialTheme.colorScheme.error
},
)
}
SkipToggle(isSkipped = isSkipped, onToggle = onToggleSkip)
}
}
}
@Composable
private fun NonImageRow(item: PreviewItem.NonImage) {
private fun NonImageRow(
item: PreviewItem.NonImage,
isSkipped: Boolean,
onToggleSkip: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (isSkipped) 0.15f else 0.35f),
) {
Row(
modifier = Modifier.padding(10.dp),
@@ -320,24 +414,73 @@ private fun NonImageRow(item: PreviewItem.NonImage) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"Metadata preserved — EXIF strip applies to JPEG only",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
SkipToggle(isSkipped = isSkipped, onToggle = onToggleSkip)
}
}
}
@Composable
private fun Thumbnail(file: java.io.File) {
private fun Thumbnail(
file: java.io.File,
dimmed: Boolean = false,
) {
AsyncImage(
model = file,
contentDescription = file.name,
modifier =
Modifier
.size(64.dp)
.clip(RoundedCornerShape(4.dp)),
.clip(RoundedCornerShape(4.dp))
.alpha(if (dimmed) 0.4f else 1f),
contentScale = ContentScale.Crop,
)
}
/**
* Skip toggle for a preview row. Includes the small "Skip" /
* "Include" label next to the Switch so the verb is unambiguous —
* a bare Switch leaves users guessing whether ON means included
* or skipped.
*/
@Composable
private fun SkipToggle(
isSkipped: Boolean,
onToggle: () -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (isSkipped) "Skipped" else "Include",
style = MaterialTheme.typography.labelSmall,
color =
if (isSkipped) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.primary
},
)
Spacer(Modifier.width(4.dp))
Switch(
checked = !isSkipped,
onCheckedChange = { onToggle() },
colors = SwitchDefaults.colors(),
)
}
}
/** Metadata-strip wording for the pass-through rows. */
private fun passThroughMetadataHint(reason: PassReason): String =
when (reason) {
PassReason.Animated -> "Metadata preserved (animated — re-encode would drop frames)"
PassReason.Vector -> "No raster metadata (SVG)"
PassReason.BypassByUser -> "Metadata preserved per your override"
}
// ---------- zoom sub-dialog ----------
@Composable