mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
feat: add video and audio picker buttons and upload support in Composer
- Add videoUri, audioUri, uploadedVideoUrl, uploadedAudioUrl, isUploadingMedia to ComposerUiState - Add setVideo/removeVideo/setAudio/removeAudio methods to ComposerViewModel - Update submitPost to upload video/audio via uploadMediaFile and pass URLs to MobiledocBuilder - Save videoUri/audioUri to LocalPost for offline queue - Add Video and Audio picker buttons to composer toolbar - Add MediaPreviewCard composable showing filename, file size, and remove button - Update PostUploadWorker to upload video/audio before building mobiledoc
This commit is contained in:
parent
96e2799787
commit
27782893dc
3 changed files with 232 additions and 4 deletions
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<Uri> = 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue