diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index 5c92c4b..01b07a7 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -110,8 +110,22 @@ fun ComposerScreen( } } + // Video picker + val videoPickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { viewModel.setVideo(it) } + } + + // Audio picker + val audioPickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { viewModel.setAudio(it) } + } + val canSubmit by remember { - derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty()) } + derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty() || state.videoUri != null || state.audioUri != null) } } Scaffold( @@ -381,6 +395,12 @@ fun ComposerScreen( OutlinedIconButton(onClick = { multiImagePickerLauncher.launch("image/*") }) { Icon(Icons.Default.Image, "Attach images") } + OutlinedIconButton(onClick = { videoPickerLauncher.launch("video/*") }) { + Icon(Icons.Default.Videocam, "Attach video") + } + OutlinedIconButton(onClick = { audioPickerLauncher.launch("audio/*") }) { + Icon(Icons.Default.MusicNote, "Attach audio") + } OutlinedIconButton(onClick = { showLinkDialog = true }) { Icon(Icons.Default.Link, "Add link") } @@ -433,6 +453,40 @@ fun ComposerScreen( } } + // Video preview card + AnimatedVisibility( + visible = state.videoUri != null, + enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()), + exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick()) + ) { + Column { + Spacer(modifier = Modifier.height(12.dp)) + MediaPreviewCard( + icon = Icons.Default.Videocam, + label = "Video attached", + uri = state.videoUri, + onRemove = viewModel::removeVideo + ) + } + } + + // Audio preview card + AnimatedVisibility( + visible = state.audioUri != null, + enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()), + exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick()) + ) { + Column { + Spacer(modifier = Modifier.height(12.dp)) + MediaPreviewCard( + icon = Icons.Default.MusicNote, + label = "Audio attached", + uri = state.audioUri, + onRemove = viewModel::removeAudio + ) + } + } + // Link preview AnimatedVisibility( visible = state.isLoadingLink, @@ -884,6 +938,95 @@ class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTrans } } +/** + * Compact preview card for attached video or audio files. + * Shows an icon, label, filename (from URI), and a remove button. + */ +@Composable +fun MediaPreviewCard( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + uri: Uri?, + onRemove: () -> Unit +) { + val context = LocalContext.current + val fileName = remember(uri) { + uri?.let { mediaUri -> + try { + context.contentResolver.query(mediaUri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (cursor.moveToFirst() && nameIndex >= 0) { + cursor.getString(nameIndex) + } else null + } + } catch (_: Exception) { + null + } ?: mediaUri.lastPathSegment ?: "Unknown file" + } ?: "Unknown file" + } + + val fileSize = remember(uri) { + uri?.let { mediaUri -> + try { + context.contentResolver.query(mediaUri, null, null, null, null)?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE) + if (cursor.moveToFirst() && sizeIndex >= 0) { + val bytes = cursor.getLong(sizeIndex) + when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + else -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + } + } else null + } + } catch (_: Exception) { + null + } + } + } + + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = fileName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + if (fileSize != null) { + Text( + text = fileSize, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + IconButton(onClick = onRemove) { + Icon(Icons.Default.Close, "Remove $label", Modifier.size(18.dp)) + } + } + } +} + /** * Tag input section with autocomplete suggestions and tag chips. */ diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index be46fc8..714e07e 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -156,6 +156,22 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application } } + fun setVideo(uri: Uri) { + _uiState.update { it.copy(videoUri = uri) } + } + + fun removeVideo() { + _uiState.update { it.copy(videoUri = null, uploadedVideoUrl = null) } + } + + fun setAudio(uri: Uri) { + _uiState.update { it.copy(audioUri = uri) } + } + + fun removeAudio() { + _uiState.update { it.copy(audioUri = null, uploadedAudioUrl = null) } + } + fun fetchLinkPreview(url: String) { if (url.isBlank()) return viewModelScope.launch { @@ -234,7 +250,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) { val state = _uiState.value - if (state.text.isBlank() && state.imageUris.isEmpty()) return + if (state.text.isBlank() && state.imageUris.isEmpty() && state.videoUri == null && state.audioUri == null) return viewModelScope.launch { _uiState.update { it.copy(isSubmitting = true, error = null) } @@ -259,6 +275,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application imageUri = state.imageUris.firstOrNull()?.toString(), imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }), imageAlt = altText, + videoUri = state.videoUri?.toString(), + audioUri = state.audioUri?.toString(), linkUrl = state.linkPreview?.url, linkTitle = state.linkPreview?.title, linkDescription = state.linkPreview?.description, @@ -290,12 +308,44 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application ) } + // Upload video if present + var videoUrl: String? = state.uploadedVideoUrl + if (videoUrl == null && state.videoUri != null) { + _uiState.update { it.copy(isUploadingMedia = true) } + val videoResult = repository.uploadMediaFile(state.videoUri) + videoResult.fold( + onSuccess = { url -> videoUrl = url }, + onFailure = { e -> + _uiState.update { it.copy(isSubmitting = false, isUploadingMedia = false, error = "Video upload failed: ${e.message}") } + return@launch + } + ) + } + + // Upload audio if present + var audioUrl: String? = state.uploadedAudioUrl + if (audioUrl == null && state.audioUri != null) { + _uiState.update { it.copy(isUploadingMedia = true) } + val audioResult = repository.uploadMediaFile(state.audioUri) + audioResult.fold( + onSuccess = { url -> audioUrl = url }, + onFailure = { e -> + _uiState.update { it.copy(isSubmitting = false, isUploadingMedia = false, error = "Audio upload failed: ${e.message}") } + return@launch + } + ) + } + + _uiState.update { it.copy(isUploadingMedia = false) } + val featureImage = uploadedImageUrls.firstOrNull() val mobiledoc = MobiledocBuilder.build( state.text, uploadedImageUrls, state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description, - altText + altText, + videoUrl, + audioUrl ) val ghostTags = allTags.map { GhostTag(name = it) } @@ -336,6 +386,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application uploadedImageUrl = featureImage, uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls), imageAlt = altText, + videoUri = state.videoUri?.toString(), + uploadedVideoUrl = videoUrl, + audioUri = state.audioUri?.toString(), + uploadedAudioUrl = audioUrl, linkUrl = state.linkPreview?.url, linkTitle = state.linkPreview?.title, linkDescription = state.linkPreview?.description, @@ -369,6 +423,11 @@ data class ComposerUiState( val text: String = "", val imageUris: List = emptyList(), val imageAlt: String = "", + val videoUri: Uri? = null, + val audioUri: Uri? = null, + val uploadedVideoUrl: String? = null, + val uploadedAudioUrl: String? = null, + val isUploadingMedia: Boolean = false, val linkPreview: LinkPreview? = null, val isLoadingLink: Boolean = false, val scheduledAt: String? = null, diff --git a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt index 7ebb324..cd8bcee 100644 --- a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -66,9 +66,35 @@ class PostUploadWorker( featureImage = allImageUrls.first() } + // Upload video if needed + var videoUrl = post.uploadedVideoUrl + if (videoUrl == null && post.videoUri != null) { + val videoResult = repository.uploadMediaFile(Uri.parse(post.videoUri)) + if (videoResult.isFailure) { + repository.updateQueueStatus(post.localId, QueueStatus.FAILED) + allSuccess = false + continue + } + videoUrl = videoResult.getOrNull() + } + + // Upload audio if needed + var audioUrl = post.uploadedAudioUrl + if (audioUrl == null && post.audioUri != null) { + val audioResult = repository.uploadMediaFile(Uri.parse(post.audioUri)) + if (audioResult.isFailure) { + repository.updateQueueStatus(post.localId, QueueStatus.FAILED) + allSuccess = false + continue + } + audioUrl = audioResult.getOrNull() + } + val mobiledoc = MobiledocBuilder.build( post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription, - post.imageAlt + post.imageAlt, + videoUrl, + audioUrl ) // Parse tags from JSON stored in LocalPost