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(