mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +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 {
|
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(
|
Scaffold(
|
||||||
|
|
@ -381,6 +395,12 @@ fun ComposerScreen(
|
||||||
OutlinedIconButton(onClick = { multiImagePickerLauncher.launch("image/*") }) {
|
OutlinedIconButton(onClick = { multiImagePickerLauncher.launch("image/*") }) {
|
||||||
Icon(Icons.Default.Image, "Attach images")
|
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 }) {
|
OutlinedIconButton(onClick = { showLinkDialog = true }) {
|
||||||
Icon(Icons.Default.Link, "Add link")
|
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
|
// Link preview
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = state.isLoadingLink,
|
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.
|
* 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) {
|
fun fetchLinkPreview(url: String) {
|
||||||
if (url.isBlank()) return
|
if (url.isBlank()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -234,7 +250,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
|
|
||||||
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
|
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
|
||||||
val state = _uiState.value
|
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 {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
||||||
|
|
@ -259,6 +275,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
imageUri = state.imageUris.firstOrNull()?.toString(),
|
imageUri = state.imageUris.firstOrNull()?.toString(),
|
||||||
imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }),
|
imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }),
|
||||||
imageAlt = altText,
|
imageAlt = altText,
|
||||||
|
videoUri = state.videoUri?.toString(),
|
||||||
|
audioUri = state.audioUri?.toString(),
|
||||||
linkUrl = state.linkPreview?.url,
|
linkUrl = state.linkPreview?.url,
|
||||||
linkTitle = state.linkPreview?.title,
|
linkTitle = state.linkPreview?.title,
|
||||||
linkDescription = state.linkPreview?.description,
|
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 featureImage = uploadedImageUrls.firstOrNull()
|
||||||
|
|
||||||
val mobiledoc = MobiledocBuilder.build(
|
val mobiledoc = MobiledocBuilder.build(
|
||||||
state.text, uploadedImageUrls,
|
state.text, uploadedImageUrls,
|
||||||
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
|
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
|
||||||
altText
|
altText,
|
||||||
|
videoUrl,
|
||||||
|
audioUrl
|
||||||
)
|
)
|
||||||
val ghostTags = allTags.map { GhostTag(name = it) }
|
val ghostTags = allTags.map { GhostTag(name = it) }
|
||||||
|
|
||||||
|
|
@ -336,6 +386,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
uploadedImageUrl = featureImage,
|
uploadedImageUrl = featureImage,
|
||||||
uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls),
|
uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls),
|
||||||
imageAlt = altText,
|
imageAlt = altText,
|
||||||
|
videoUri = state.videoUri?.toString(),
|
||||||
|
uploadedVideoUrl = videoUrl,
|
||||||
|
audioUri = state.audioUri?.toString(),
|
||||||
|
uploadedAudioUrl = audioUrl,
|
||||||
linkUrl = state.linkPreview?.url,
|
linkUrl = state.linkPreview?.url,
|
||||||
linkTitle = state.linkPreview?.title,
|
linkTitle = state.linkPreview?.title,
|
||||||
linkDescription = state.linkPreview?.description,
|
linkDescription = state.linkPreview?.description,
|
||||||
|
|
@ -369,6 +423,11 @@ data class ComposerUiState(
|
||||||
val text: String = "",
|
val text: String = "",
|
||||||
val imageUris: List<Uri> = emptyList(),
|
val imageUris: List<Uri> = emptyList(),
|
||||||
val imageAlt: String = "",
|
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 linkPreview: LinkPreview? = null,
|
||||||
val isLoadingLink: Boolean = false,
|
val isLoadingLink: Boolean = false,
|
||||||
val scheduledAt: String? = null,
|
val scheduledAt: String? = null,
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,35 @@ class PostUploadWorker(
|
||||||
featureImage = allImageUrls.first()
|
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(
|
val mobiledoc = MobiledocBuilder.build(
|
||||||
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription,
|
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription,
|
||||||
post.imageAlt
|
post.imageAlt,
|
||||||
|
videoUrl,
|
||||||
|
audioUrl
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parse tags from JSON stored in LocalPost
|
// Parse tags from JSON stored in LocalPost
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue