feat: add audio and PDF file upload support to ShortNotePostScreen

Add a new "attach file" button that opens a document picker filtered to
audio/* and application/pdf MIME types. This uses OpenMultipleDocuments
contract since PickVisualMedia only supports images/videos.

- Add SelectFromFiles composable with AttachFile icon button
- Add isAudio() and isDocument() helpers to SelectedMedia
- Add FilePreviewPlaceholder for audio/PDF thumbnail display
- Add upload_file string resource

https://claude.ai/code/session_018naEzfHjLRQLgaWAtY4aSz
This commit is contained in:
Claude
2026-03-10 14:48:47 +00:00
parent f42939c3b0
commit a7cec6f5fd
5 changed files with 193 additions and 0 deletions
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.uploads
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.concurrent.atomic.AtomicBoolean
@Composable
fun SelectFromFiles(
isUploading: Boolean,
tint: Color,
modifier: Modifier,
onFilesChosen: (ImmutableList<SelectedMedia>) -> Unit,
) {
var showFileSelect by remember { mutableStateOf(false) }
if (showFileSelect) {
FileSelect(
onFilesSelected = { files ->
showFileSelect = false
if (files.isNotEmpty()) {
onFilesChosen(files)
}
},
)
}
FileSelectButton(isUploading, tint, modifier) { showFileSelect = true }
}
@Composable
private fun FileSelectButton(
isUploading: Boolean,
tint: Color,
modifier: Modifier,
onClick: () -> Unit,
) {
IconButton(
modifier = modifier,
enabled = !isUploading,
onClick = { onClick() },
) {
if (!isUploading) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = stringRes(id = R.string.upload_file),
modifier = Modifier.height(25.dp),
tint = tint,
)
} else {
LoadingAnimation()
}
}
}
@Composable
fun FileSelect(onFilesSelected: (ImmutableList<SelectedMedia>) -> Unit = {}) {
val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
val resolver = LocalContext.current.contentResolver
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments(),
onResult = { uris: List<Uri> ->
onFilesSelected(
uris
.map {
SelectedMedia(it, resolver.getType(it))
}.toImmutableList(),
)
hasLaunched.set(false)
},
)
@Composable
fun LaunchFilePicker() {
SideEffect {
if (!hasLaunched.getAndSet(true)) {
launcher.launch(
arrayOf(
"audio/*",
"application/pdf",
),
)
}
}
}
LaunchFilePicker()
}
@@ -55,6 +55,10 @@ class SelectedMedia(
fun isImage() = mimeType?.startsWith("image")
fun isVideo() = mimeType?.startsWith("video")
fun isAudio() = mimeType?.startsWith("audio")
fun isDocument() = mimeType == "application/pdf"
}
@Composable
@@ -28,14 +28,21 @@ import android.os.Build
import androidx.compose.animation.core.animateFloatAsState
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AudioFile
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
@@ -117,6 +124,16 @@ fun ShowImageGallery(
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
)
} else if (media.isAudio() == true) {
FilePreviewPlaceholder(
icon = Icons.Default.AudioFile,
label = media.mimeType ?: "audio/*",
)
} else if (media.isDocument() == true) {
FilePreviewPlaceholder(
icon = Icons.Default.PictureAsPdf,
label = media.mimeType ?: "application/pdf",
)
} else if (media.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
val context = LocalContext.current
@@ -254,3 +271,36 @@ fun UploadingState(
)
}
}
@Composable
fun FilePreviewPlaceholder(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(8.dp),
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
textAlign = TextAlign.Center,
)
}
}
}
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
@@ -504,6 +505,14 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
maxDurationSeconds = MAX_VOICE_RECORD_SECONDS,
)
SelectFromFiles(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
postViewModel.selectImage(it)
}
if (postViewModel.canUsePoll) {
// These should be hashtag recommendations the user selects in the future.
// val hashtag = stringRes(R.string.poll_hashtag)
+1
View File
@@ -170,6 +170,7 @@
<string name="video_saved_to_the_gallery">Video saved to the phone\'s video gallery</string>
<string name="failed_to_save_the_video">Failed to save the video</string>
<string name="upload_image">Upload Image</string>
<string name="upload_file">Upload File</string>
<string name="take_a_picture">Take a picture</string>
<string name="record_a_video">Record a video</string>
<string name="record_a_message">Record a message</string>