diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 04da65b..a587200 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -32,6 +32,7 @@
+
diff --git a/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt
index 17452ce..fdfac9e 100644
--- a/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt
+++ b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt
@@ -39,8 +39,8 @@ class MainActivity : ComponentActivity() {
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) }
val picker = rememberLauncherForActivityResult(
- ActivityResultContracts.PickVisualMedia(),
- ) { uri -> uri?.let(viewModel::selectImage) }
+ ActivityResultContracts.PickMultipleVisualMedia(),
+ ) { uris -> if (uris.isNotEmpty()) viewModel.selectImages(uris) }
val notificationPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
@@ -73,7 +73,7 @@ class MainActivity : ComponentActivity() {
} else {
PostScreen(
state = viewModel.post,
- onPickImage = {
+ onPickImages = {
picker.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly,
@@ -82,6 +82,7 @@ class MainActivity : ComponentActivity() {
},
onCaptionChange = viewModel::setCaption,
onDateChange = viewModel::setDate,
+ onRemove = viewModel::removeItem,
onSubmit = viewModel::submit,
onReset = viewModel::clear,
onOpenSettings = { showSettings = true },
@@ -98,16 +99,31 @@ class MainActivity : ComponentActivity() {
consumeShareIntent(intent)
}
- /** Picks up a photo handed over by the share sheet. */
+ /** Picks up one or several photos handed over by the share sheet. */
private fun consumeShareIntent(intent: Intent?) {
- if (intent?.action != Intent.ACTION_SEND) return
- val uri: Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
- } else {
- @Suppress("DEPRECATION")
- intent.getParcelableExtra(Intent.EXTRA_STREAM)
+ val uris: List = when (intent?.action) {
+ Intent.ACTION_SEND -> {
+ val uri: Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(Intent.EXTRA_STREAM)
+ }
+ listOfNotNull(uri)
+ }
+
+ Intent.ACTION_SEND_MULTIPLE -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
+ }.orEmpty()
+ }
+
+ else -> return
}
- uri?.let(viewModel::selectImage)
+ if (uris.isNotEmpty()) viewModel.selectImages(uris)
}
private fun openUrl(url: String) {
diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt
index 046a3c8..60df797 100644
--- a/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt
+++ b/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt
@@ -1,17 +1,20 @@
package ws.inflo.photogallery.ui
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
@@ -36,8 +39,6 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@@ -48,144 +49,214 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM
@Composable
fun PostScreen(
state: PostUiState,
- onPickImage: () -> Unit,
- onCaptionChange: (String) -> Unit,
- onDateChange: (LocalDate) -> Unit,
+ onPickImages: () -> Unit,
+ onCaptionChange: (id: String, value: String) -> Unit,
+ onDateChange: (id: String, value: LocalDate) -> Unit,
+ onRemove: (id: String) -> Unit,
onSubmit: () -> Unit,
onReset: () -> Unit,
onOpenSettings: () -> Unit,
onOpenUrl: (String) -> Unit,
) {
- var datePickerOpen by remember { mutableStateOf(false) }
+ // Which item's date is being edited, if any.
+ var dateEditFor by remember { mutableStateOf(null) }
+
+ val pendingCount = state.items.count { it.status !is ItemStatus.Success }
+ val canPost = state.hasItems && !state.posting && pendingCount > 0
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
- Column(
+ LazyColumn(
modifier = Modifier
.fillMaxSize()
- .verticalScroll(rememberScrollState())
.padding(20.dp),
) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text("Post a photo", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
- TextButton(onClick = onOpenSettings) { Text("Settings") }
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Post photos", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
+ TextButton(onClick = onOpenSettings) { Text("Settings") }
+ }
+ Spacer(Modifier.height(12.dp))
}
- Spacer(Modifier.height(12.dp))
+ item {
+ OutlinedButton(onClick = onPickImages, modifier = Modifier.fillMaxWidth()) {
+ Text(if (state.hasItems) "Choose different photos" else "Choose photos")
+ }
+ Spacer(Modifier.height(16.dp))
+ }
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .aspectRatio(1f)
- .background(GruvBg1),
- contentAlignment = Alignment.Center,
- ) {
- val preview = state.preview
- when {
- preview != null -> Image(
- bitmap = preview.asImageBitmap(),
- contentDescription = null,
- contentScale = ContentScale.Fit,
- modifier = Modifier.fillMaxSize(),
- )
- state.imageUri != null -> CircularProgressIndicator(color = GruvAqua)
- else -> Text("No photo selected", color = GruvFg4)
+ if (!state.hasItems) {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(160.dp)
+ .background(GruvBg1),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("No photos selected", color = GruvFg4)
+ }
}
}
- Spacer(Modifier.height(12.dp))
-
- OutlinedButton(onClick = onPickImage, modifier = Modifier.fillMaxWidth()) {
- Text(if (state.imageUri == null) "Choose photo" else "Choose a different photo")
+ items(state.items, key = { it.id }) { item ->
+ ItemCard(
+ item = item,
+ onCaptionChange = { onCaptionChange(item.id, it) },
+ onEditDate = { dateEditFor = item },
+ onRemove = { onRemove(item.id) },
+ onOpenUrl = onOpenUrl,
+ )
+ Spacer(Modifier.height(12.dp))
}
- Spacer(Modifier.height(16.dp))
-
- OutlinedTextField(
- value = state.caption,
- onValueChange = onCaptionChange,
- label = { Text("Caption") },
- placeholder = { Text("Defaults to the filename if left blank") },
- modifier = Modifier.fillMaxWidth(),
- )
-
- Spacer(Modifier.height(12.dp))
-
- OutlinedButton(
- onClick = { datePickerOpen = true },
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text("Date: ${state.date.format(DISPLAY_DATE)}")
- }
-
- Spacer(Modifier.height(20.dp))
-
- Button(
- onClick = onSubmit,
- enabled = state.imageUri != null && state.status !is PostStatus.Uploading,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(if (state.status is PostStatus.Uploading) "Posting…" else "Post")
- }
-
- Spacer(Modifier.height(16.dp))
-
- when (val status = state.status) {
- is PostStatus.Error -> Text(status.message, color = GruvRed)
-
- is PostStatus.Success -> Column {
- Text("Posted.", color = GruvAqua)
- status.note?.let {
- Spacer(Modifier.height(4.dp))
- Text("Note: $it", color = GruvFg4, style = MaterialTheme.typography.bodySmall)
- }
+ if (state.hasItems) {
+ item {
Spacer(Modifier.height(8.dp))
- Row {
- TextButton(onClick = { onOpenUrl(status.url) }) { Text("Open") }
- TextButton(onClick = onReset) { Text("Post another") }
+ Button(
+ onClick = onSubmit,
+ enabled = canPost,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ when {
+ state.posting -> "Posting… (${state.succeeded}/${state.items.size})"
+ pendingCount == 0 -> "All posted"
+ pendingCount == 1 -> "Post 1 photo"
+ else -> "Post $pendingCount photos"
+ },
+ )
+ }
+
+ if (state.allDone) {
+ Spacer(Modifier.height(12.dp))
+ val summary = buildString {
+ append("${state.succeeded} posted")
+ if (state.failed > 0) append(", ${state.failed} failed")
+ }
+ Text(summary, color = if (state.failed > 0) GruvRed else GruvAqua)
+ Spacer(Modifier.height(8.dp))
+ TextButton(onClick = onReset) { Text("Clear") }
}
}
-
- else -> Unit
}
state.lastPost?.let { last ->
- Spacer(Modifier.height(24.dp))
- HorizontalDivider(color = GruvBg2)
- Spacer(Modifier.height(12.dp))
- Text("Last post", color = GruvFg4, style = MaterialTheme.typography.labelMedium)
- Spacer(Modifier.height(4.dp))
- Text(
- last.caption.ifBlank { last.slug },
- color = GruvFg,
- style = MaterialTheme.typography.bodyMedium,
- )
- Text(
- last.slug,
- color = GruvFg4,
- fontFamily = FontFamily.Monospace,
- style = MaterialTheme.typography.bodySmall,
- )
- TextButton(onClick = { onOpenUrl(last.url) }) { Text("Open") }
+ item {
+ Spacer(Modifier.height(24.dp))
+ HorizontalDivider(color = GruvBg2)
+ Spacer(Modifier.height(12.dp))
+ Text("Last post", color = GruvFg4, style = MaterialTheme.typography.labelMedium)
+ Spacer(Modifier.height(4.dp))
+ Text(
+ last.caption.ifBlank { last.slug },
+ color = GruvFg,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Text(
+ last.slug,
+ color = GruvFg4,
+ fontFamily = FontFamily.Monospace,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ TextButton(onClick = { onOpenUrl(last.url) }) { Text("Open") }
+ }
}
}
}
- if (datePickerOpen) {
+ dateEditFor?.let { editing ->
DatePickerModal(
- initial = state.date,
- onDismiss = { datePickerOpen = false },
+ initial = editing.date,
+ onDismiss = { dateEditFor = null },
onConfirm = {
- onDateChange(it)
- datePickerOpen = false
+ onDateChange(editing.id, it)
+ dateEditFor = null
},
)
}
}
+@Composable
+private fun ItemCard(
+ item: PickedItem,
+ onCaptionChange: (String) -> Unit,
+ onEditDate: () -> Unit,
+ onRemove: () -> Unit,
+ onOpenUrl: (String) -> Unit,
+) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Box(
+ modifier = Modifier
+ .size(84.dp)
+ .background(GruvBg1),
+ contentAlignment = Alignment.Center,
+ ) {
+ val preview = item.preview
+ when {
+ preview != null -> Image(
+ bitmap = preview.asImageBitmap(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ else -> CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(24.dp))
+ }
+ }
+
+ Spacer(Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ OutlinedTextField(
+ value = item.caption,
+ onValueChange = onCaptionChange,
+ label = { Text("Caption") },
+ singleLine = true,
+ enabled = item.status !is ItemStatus.Uploading,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(6.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ OutlinedButton(
+ onClick = onEditDate,
+ enabled = item.status !is ItemStatus.Uploading,
+ ) {
+ Text(item.date.format(DISPLAY_DATE))
+ }
+ Spacer(Modifier.width(8.dp))
+ ItemStatusLabel(item.status, onOpenUrl)
+ }
+ }
+
+ Spacer(Modifier.width(8.dp))
+
+ when (item.status) {
+ is ItemStatus.Uploading ->
+ CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(20.dp))
+ is ItemStatus.Success -> Unit
+ else -> TextButton(onClick = onRemove) { Text("✕", color = GruvFg4) }
+ }
+ }
+}
+
+@Composable
+private fun ItemStatusLabel(status: ItemStatus, onOpenUrl: (String) -> Unit) {
+ when (status) {
+ is ItemStatus.Pending -> Unit
+ is ItemStatus.Uploading -> Text("Uploading…", color = GruvFg4, style = MaterialTheme.typography.bodySmall)
+ is ItemStatus.Success -> TextButton(onClick = { onOpenUrl(status.url) }) { Text("View") }
+ is ItemStatus.Error -> Text(
+ status.message,
+ color = GruvRed,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DatePickerModal(
diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt
index 437ab10..2bf4d37 100644
--- a/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt
+++ b/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt
@@ -24,22 +24,41 @@ import java.io.File
import java.time.LocalDate
import java.util.UUID
-sealed interface PostStatus {
- data object Idle : PostStatus
- data object Uploading : PostStatus
- data class Success(val url: String, val note: String?) : PostStatus
- data class Error(val message: String) : PostStatus
+/** Per-photo status within a batch. */
+sealed interface ItemStatus {
+ data object Pending : ItemStatus
+ data object Uploading : ItemStatus
+ data class Success(val url: String, val note: String?) : ItemStatus
+ data class Error(val message: String) : ItemStatus
}
-data class PostUiState(
- val imageUri: Uri? = null,
+/**
+ * One photo in the batch. A single-photo post (picker or share) is just a
+ * batch of one, so the whole screen runs a single code path.
+ */
+data class PickedItem(
+ val id: String = UUID.randomUUID().toString(),
+ val source: Uri,
val preview: Bitmap? = null,
val caption: String = "",
val date: LocalDate = LocalDate.now(),
- val status: PostStatus = PostStatus.Idle,
- val lastPost: LastPost? = null,
+ val status: ItemStatus = ItemStatus.Pending,
)
+data class PostUiState(
+ val items: List = emptyList(),
+ val lastPost: LastPost? = null,
+) {
+ val posting: Boolean get() = items.any { it.status is ItemStatus.Uploading }
+ val hasItems: Boolean get() = items.isNotEmpty()
+ val succeeded: Int get() = items.count { it.status is ItemStatus.Success }
+ val failed: Int get() = items.count { it.status is ItemStatus.Error }
+ /** True once every item has reached a terminal state and at least one ran. */
+ val allDone: Boolean
+ get() = items.isNotEmpty() &&
+ items.all { it.status is ItemStatus.Success || it.status is ItemStatus.Error }
+}
+
data class SettingsUiState(
val baseUrl: String = "",
val username: String = "",
@@ -69,57 +88,81 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
// ── Post screen ─────────────────────────────────────────────────────────
- fun selectImage(uri: Uri) {
- post = post.copy(imageUri = uri, status = PostStatus.Idle, preview = null)
- viewModelScope.launch {
- val context = getApplication()
- val preview = withContext(Dispatchers.IO) { ImagePrep.preview(context, uri) }
- val captured = withContext(Dispatchers.IO) { ImagePrep.captureDate(context, uri) }
- post = post.copy(preview = preview, date = captured ?: LocalDate.now())
+ /** Replaces the current selection with [uris] and loads each preview. */
+ fun selectImages(uris: List) {
+ if (uris.isEmpty()) return
+ val items = uris.map { PickedItem(source = it) }
+ post = post.copy(items = items)
+
+ val context = getApplication()
+ for (item in items) {
+ viewModelScope.launch {
+ val preview = withContext(Dispatchers.IO) {
+ ImagePrep.preview(context, item.source, maxDimension = 256)
+ }
+ val captured = withContext(Dispatchers.IO) {
+ ImagePrep.captureDate(context, item.source)
+ }
+ updateItem(item.id) {
+ it.copy(preview = preview, date = captured ?: it.date)
+ }
+ }
}
}
- fun setCaption(value: String) {
- post = post.copy(caption = value)
+ fun setCaption(id: String, value: String) {
+ updateItem(id) { it.copy(caption = value) }
}
- fun setDate(value: LocalDate) {
- post = post.copy(date = value)
+ fun setDate(id: String, value: LocalDate) {
+ updateItem(id) { it.copy(date = value) }
+ }
+
+ fun removeItem(id: String) {
+ // A photo already uploading keeps its worker; just drop it from the UI.
+ post = post.copy(items = post.items.filterNot { it.id == id })
}
fun clear() {
post = PostUiState(lastPost = settings.lastPost)
}
+ /** Uploads every item not already posted. Re-running retries only failures. */
fun submit() {
- val uri = post.imageUri ?: return
if (!settings.isConfigured) {
- post = post.copy(status = PostStatus.Error("Set the server URL and credentials first"))
+ post = post.copy(
+ items = post.items.map {
+ if (it.status is ItemStatus.Success) it
+ else it.copy(status = ItemStatus.Error("Set the server URL and credentials first"))
+ },
+ )
return
}
- post = post.copy(status = PostStatus.Uploading)
+ val pending = post.items.filter { it.status !is ItemStatus.Success }
+ if (pending.isEmpty()) return
- viewModelScope.launch {
- val context = getApplication()
-
- // Stage the bytes into our own cache before handing the job to
- // WorkManager. A content:// URI from the share sheet is only
- // readable while the grant lasts, and a retry can fire long after
- // the activity that received it is gone.
- val staged = withContext(Dispatchers.IO) { stage(uri) }
- if (staged == null) {
- post = post.copy(status = PostStatus.Error("Could not read the selected image"))
- return@launch
+ val context = getApplication()
+ for (item in pending) {
+ updateItem(item.id) { it.copy(status = ItemStatus.Uploading) }
+ viewModelScope.launch {
+ val staged = withContext(Dispatchers.IO) { stage(item.source) }
+ if (staged == null) {
+ updateItem(item.id) {
+ it.copy(status = ItemStatus.Error("Could not read the selected image"))
+ }
+ return@launch
+ }
+ val current = post.items.firstOrNull { it.id == item.id } ?: return@launch
+ val workId = UploadQueue.enqueue(
+ context = context,
+ uri = Uri.fromFile(staged),
+ caption = current.caption,
+ date = current.date,
+ uniqueSuffix = item.id.take(6),
+ )
+ observe(item.id, workId)
}
-
- val id = UploadQueue.enqueue(
- context = context,
- uri = Uri.fromFile(staged),
- caption = post.caption,
- date = post.date,
- )
- observe(id)
}
}
@@ -136,32 +179,35 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
}
}
- private fun observe(id: UUID) {
+ private fun observe(itemId: String, workId: UUID) {
viewModelScope.launch {
- WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(id).collect { info ->
+ WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(workId).collect { info ->
if (info == null) return@collect
when (info.state) {
WorkInfo.State.SUCCEEDED -> {
- post = post.copy(
- status = PostStatus.Success(
- url = info.outputData.getString(UploadWorker.KEY_RESULT_URL).orEmpty(),
- note = info.outputData.getString(UploadWorker.KEY_RESULT_NOTE),
- ),
- lastPost = settings.lastPost,
- )
+ updateItem(itemId) {
+ it.copy(
+ status = ItemStatus.Success(
+ url = info.outputData.getString(UploadWorker.KEY_RESULT_URL).orEmpty(),
+ note = info.outputData.getString(UploadWorker.KEY_RESULT_NOTE),
+ ),
+ )
+ }
+ post = post.copy(lastPost = settings.lastPost)
}
- WorkInfo.State.FAILED -> {
- post = post.copy(
- status = PostStatus.Error(
+ WorkInfo.State.FAILED -> updateItem(itemId) {
+ it.copy(
+ status = ItemStatus.Error(
info.outputData.getString(UploadWorker.KEY_RESULT_ERROR)
?: "Upload failed",
),
)
}
- WorkInfo.State.CANCELLED ->
- post = post.copy(status = PostStatus.Error("Upload cancelled"))
+ WorkInfo.State.CANCELLED -> updateItem(itemId) {
+ it.copy(status = ItemStatus.Error("Upload cancelled"))
+ }
else -> Unit
}
@@ -169,6 +215,10 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
}
}
+ private inline fun updateItem(id: String, transform: (PickedItem) -> PickedItem) {
+ post = post.copy(items = post.items.map { if (it.id == id) transform(it) else it })
+ }
+
// ── Settings screen ─────────────────────────────────────────────────────
fun setBaseUrl(value: String) {
diff --git a/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt b/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt
index 120bf75..a35fae3 100644
--- a/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt
+++ b/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt
@@ -18,9 +18,11 @@ object UploadQueue {
/**
* Slug format. Uses the enqueue wall-clock time rather than the photo's
- * capture time: a burst of frames shot in the same second would collide on
- * capture time, whereas posts are made one at a time. The real capture date
- * still travels in the `date` field and lands in the .toml sidecar.
+ * capture time: capture time collides across a burst. A [uniqueSuffix] is
+ * appended so that a bulk post — several photos enqueued in the same second
+ * — cannot collide either; without it enqueueUniqueWork would silently drop
+ * the duplicates. The real capture date still travels in the `date` field
+ * and lands in the .toml sidecar.
*/
private val SLUG_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")
@@ -32,8 +34,9 @@ object UploadQueue {
uri: Uri,
caption: String,
date: LocalDate,
+ uniqueSuffix: String,
): UUID {
- val slug = LocalDateTime.now().format(SLUG_FORMAT)
+ val slug = LocalDateTime.now().format(SLUG_FORMAT) + "-" + uniqueSuffix
val request = OneTimeWorkRequestBuilder()
.setInputData(