diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt new file mode 100644 index 000000000..d7ecec0e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt @@ -0,0 +1,67 @@ +/** + * 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.mediaServers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun FileServerSelectionRow( + fileServers: List, + selectedServer: ServerName?, + onSelect: (ServerName) -> Unit, +) { + val nip95description = stringRes(id = R.string.upload_server_relays_nip95) + + val fileServerOptions = + remember(fileServers) { + fileServers + .map { + if (it.type == ServerType.NIP95) { + TitleExplainer(it.name, nip95description) + } else { + TitleExplainer(it.name, it.baseUrl) + } + }.toImmutableList() + } + + val placeholder = + fileServers + .firstOrNull { it == selectedServer } + ?.name + ?: fileServers.firstOrNull()?.name + ?: "" + + SettingsRow(R.string.file_server, R.string.file_server_description) { + TextSpinner( + label = "", + placeholder = placeholder, + options = fileServerOptions, + onSelect = { index -> fileServers.getOrNull(index)?.let(onSelect) }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt new file mode 100644 index 000000000..2720dc877 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt @@ -0,0 +1,101 @@ +/** + * 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 androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun UploadProgressIndicator( + orchestrator: UploadOrchestrator, + modifier: Modifier = Modifier, +) { + val progressValue = orchestrator.progress.collectAsState().value + val progressStatusValue = orchestrator.progressState.collectAsState().value + + Box( + modifier = + modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.size(55.dp), + contentAlignment = Alignment.Center, + ) { + val animatedProgress = + animateFloatAsState( + targetValue = progressValue.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ).value + + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = + Size55Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) + + val txt = + when (progressStatusValue) { + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) + } + + Text( + txt, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index b347e68b3..504c723be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -24,10 +24,7 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -38,47 +35,37 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.components.TextSpinner -import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -111,7 +98,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -365,24 +351,11 @@ private fun NewPostScreenBody( // Show preview for both uploaded messages (voiceMetadata) and pending recordings (postViewModel.voiceMetadata ?: postViewModel.getVoicePreviewMetadata())?.let { metadata -> - val nip95description = stringRes(id = R.string.upload_server_relays_nip95) val fileServersState = accountViewModel.account.serverLists.liveServerList .collectAsState() val fileServers = fileServersState.value - val fileServerOptions = - remember(fileServers) { - fileServers - .map { - if (it.type == ServerType.NIP95) { - TitleExplainer(it.name, nip95description) - } else { - TitleExplainer(it.name, it.baseUrl) - } - }.toImmutableList() - } - Column( modifier = Modifier @@ -391,7 +364,7 @@ private fun NewPostScreenBody( ) { // Display voice preview or uploading progress postViewModel.voiceOrchestrator?.let { orchestrator -> - VoiceUploadingProgress(orchestrator) + UploadProgressIndicator(orchestrator) } ?: run { VoiceMessagePreview( voiceMetadata = metadata, @@ -400,18 +373,11 @@ private fun NewPostScreenBody( ) } - SettingsRow(R.string.file_server, R.string.file_server_description) { - TextSpinner( - label = "", - placeholder = - fileServers - .firstOrNull { it == (postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) } - ?.name - ?: fileServers[0].name, - options = fileServerOptions, - onSelect = { postViewModel.voiceSelectedServer = fileServers[it] }, - ) - } + FileServerSelectionRow( + fileServers = fileServers, + selectedServer = postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer, + onSelect = { postViewModel.voiceSelectedServer = it }, + ) } } @@ -584,56 +550,3 @@ private fun AddPollButton( } } } - -@Composable -private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) { - val progressValue = orchestrator.progress.collectAsState().value - val progressStatusValue = orchestrator.progressState.collectAsState().value - - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - contentAlignment = androidx.compose.ui.Alignment.Center, - ) { - Box( - modifier = Modifier.size(55.dp), - contentAlignment = androidx.compose.ui.Alignment.Center, - ) { - val animatedProgress = - animateFloatAsState( - targetValue = progressValue.toFloat(), - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - ).value - - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = - Size55Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.background), - strokeWidth = 5.dp, - ) - - val txt = - when (progressStatusValue) { - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) - } - - Text( - txt, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 10.sp, - textAlign = TextAlign.Center, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index 05c9f88ac..95e0227d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -21,10 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateFloatAsState -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 @@ -33,49 +30,37 @@ 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.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox +import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.components.TextSpinner -import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier -import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -89,9 +74,9 @@ fun VoiceReplyScreen( nav: Nav, ) { val viewModel: VoiceReplyViewModel = viewModel() - viewModel.init(accountViewModel) - LaunchedEffect(replyToNoteId, recordingFilePath) { + LaunchedEffect(replyToNoteId, recordingFilePath, accountViewModel) { + viewModel.init(accountViewModel) viewModel.load(replyToNoteId, recordingFilePath, mimeType, duration, amplitudesJson) } @@ -100,6 +85,12 @@ fun VoiceReplyScreen( nav.popBack() } + DisposableEffect(Unit) { + onDispose { + viewModel.cancel() + } + } + Scaffold( topBar = { PostingTopBar( @@ -115,6 +106,9 @@ fun VoiceReplyScreen( }, ) }, + bottomBar = { + ReRecordButton(viewModel) + }, ) { pad -> Surface( modifier = @@ -160,7 +154,7 @@ private fun VoiceReplyScreenBody( // Voice preview or upload progress viewModel.voiceOrchestrator?.let { orchestrator -> - VoiceUploadingProgress(orchestrator) + UploadProgressIndicator(orchestrator) } ?: run { viewModel.getVoicePreviewMetadata()?.let { metadata -> VoiceMessagePreview( @@ -177,62 +171,47 @@ private fun VoiceReplyScreenBody( Spacer(modifier = Modifier.height(16.dp)) // Server selection - ServerSelectionRow(viewModel, accountViewModel) - - Spacer(modifier = Modifier.weight(1f)) - - // Re-record button at bottom - ReRecordButton(viewModel) - - Spacer(modifier = Modifier.height(16.dp)) - } -} - -@Composable -private fun ServerSelectionRow( - viewModel: VoiceReplyViewModel, - accountViewModel: AccountViewModel, -) { - val nip95description = stringRes(id = R.string.upload_server_relays_nip95) - val fileServersState = - accountViewModel.account.serverLists.liveServerList - .collectAsState() - val fileServers = fileServersState.value - - val fileServerOptions = - remember(fileServers) { - fileServers - .map { - if (it.type == ServerType.NIP95) { - TitleExplainer(it.name, nip95description) - } else { - TitleExplainer(it.name, it.baseUrl) - } - }.toImmutableList() - } - - SettingsRow(R.string.file_server, R.string.file_server_description) { - TextSpinner( - label = "", - placeholder = - fileServers - .firstOrNull { it == (viewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) } - ?.name - ?: fileServers.firstOrNull()?.name - ?: "", - options = fileServerOptions, - onSelect = { viewModel.voiceSelectedServer = fileServers[it] }, + val fileServers = + accountViewModel.account.serverLists.liveServerList + .collectAsState() + .value + FileServerSelectionRow( + fileServers = fileServers, + selectedServer = viewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer, + onSelect = { viewModel.voiceSelectedServer = it }, ) + + Spacer(modifier = Modifier.height(80.dp)) } } @Composable private fun ReRecordButton(viewModel: VoiceReplyViewModel) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = Size10dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { + if (viewModel.isUploading) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + tint = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringRes(id = R.string.re_record), + color = MaterialTheme.colorScheme.onBackground, + ) + } + return + } + RecordAudioBox( modifier = Modifier, onRecordTaken = { recording -> @@ -266,56 +245,3 @@ private fun ReRecordButton(viewModel: VoiceReplyViewModel) { } } } - -@Composable -private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) { - val progressValue = orchestrator.progress.collectAsState().value - val progressStatusValue = orchestrator.progressState.collectAsState().value - - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier.size(55.dp), - contentAlignment = Alignment.Center, - ) { - val animatedProgress = - animateFloatAsState( - targetValue = progressValue.toFloat(), - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - ).value - - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = - Size55Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.background), - strokeWidth = 5.dp, - ) - - val txt = - when (progressStatusValue) { - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) - } - - Text( - txt, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 10.sp, - textAlign = TextAlign.Center, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index fe99743f2..f5010cb4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -41,8 +41,11 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import java.io.File @@ -59,6 +62,8 @@ class VoiceReplyViewModel : ViewModel() { var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null) var isUploading: Boolean by mutableStateOf(false) + private var uploadJob: Job? = null + fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM } @@ -109,6 +114,7 @@ class VoiceReplyViewModel : ViewModel() { } fun removeVoiceMessage() { + cancelUpload() deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null @@ -118,6 +124,11 @@ class VoiceReplyViewModel : ViewModel() { voiceOrchestrator = null } + private fun cancelUpload() { + uploadJob?.cancel() + uploadJob = null + } + private fun deleteVoiceLocalFile() { voiceLocalFile?.let { file -> try { @@ -131,101 +142,118 @@ class VoiceReplyViewModel : ViewModel() { } } - fun canSend(): Boolean = (voiceRecording != null || voiceMetadata != null) && !isUploading + fun canSend(): Boolean = voiceRecording != null && !isUploading fun sendVoiceReply(onSuccess: () -> Unit) { val note = replyToNote ?: return val recording = voiceRecording ?: return val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer - viewModelScope.launch(Dispatchers.IO) { - uploadAndSend(note, recording, serverToUse, onSuccess) - } + cancelUpload() + uploadJob = + viewModelScope.launch { + isUploading = true + val orchestrator = UploadOrchestrator() + voiceOrchestrator = orchestrator + + try { + val result = + withContext(Dispatchers.IO) { + val uri = android.net.Uri.fromFile(recording.file) + orchestrator.upload( + uri = uri, + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = serverToUse, + account = accountViewModel.account, + context = Amethyst.instance.appContext, + useH265 = false, + ) + } + + handleUploadResult(note, recording, serverToUse, result, onSuccess) + } catch (e: CancellationException) { + // User canceled, or ViewModel cleared. + } catch (e: Exception) { + val appContext = Amethyst.instance.appContext + val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) + val uploadVoiceExceptionMessage: (String) -> String = { detail -> + stringRes(appContext, R.string.upload_error_voice_message_exception, detail) + } + accountViewModel.toastManager.toast( + uploadErrorTitle, + uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName), + ) + } finally { + isUploading = false + voiceOrchestrator = null + uploadJob = null + } + } } - private suspend fun uploadAndSend( + private suspend fun handleUploadResult( note: Note, recording: RecordingResult, server: ServerName, + result: UploadingState, onSuccess: () -> Unit, ) { val appContext = Amethyst.instance.appContext val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed) - val uploadVoiceExceptionMessage: (String) -> String = { detail -> - stringRes(appContext, R.string.upload_error_voice_message_exception, detail) - } - isUploading = true - - try { - val uri = android.net.Uri.fromFile(recording.file) - val orchestrator = UploadOrchestrator() - voiceOrchestrator = orchestrator - - val result = - orchestrator.upload( - uri = uri, - mimeType = recording.mimeType, - alt = null, - contentWarningReason = null, - compressionQuality = CompressorQuality.UNCOMPRESSED, - server = server, - account = accountViewModel.account, - context = appContext, - useH265 = false, - ) - - when (result) { - is UploadingState.Finished -> { - when (val orchestratorResult = result.result) { - is UploadOrchestrator.OrchestratorResult.ServerResult -> { - val audioMeta = - AudioMeta( - url = orchestratorResult.url, - mimeType = recording.mimeType, - hash = orchestratorResult.fileHeader.hash, - duration = recording.duration, - waveform = recording.amplitudes, - ) - - val hint = note.toEventHint() - if (hint != null) { - accountViewModel.account.signAndComputeBroadcast( - VoiceReplyEvent.build(audioMeta, hint), - ) - } - - if (server.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(server) - } - - deleteVoiceLocalFile() - voiceLocalFile = null - voiceRecording = null - voiceMetadata = audioMeta - - onSuccess() + when (result) { + is UploadingState.Finished -> { + when (val orchestratorResult = result.result) { + is UploadOrchestrator.OrchestratorResult.ServerResult -> { + val hint = note.toEventHint() + if (hint == null) { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + return } - is UploadOrchestrator.OrchestratorResult.NIP95Result -> { - accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceNip95NotSupported) + + val audioMeta = + AudioMeta( + url = orchestratorResult.url, + mimeType = recording.mimeType, + hash = orchestratorResult.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, + ) + + accountViewModel.account.signAndComputeBroadcast(VoiceReplyEvent.build(audioMeta, hint)) + + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) } + + deleteVoiceLocalFile() + voiceLocalFile = null + voiceRecording = null + voiceMetadata = audioMeta + + onSuccess() + } + is UploadOrchestrator.OrchestratorResult.NIP95Result -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceNip95NotSupported) } } - is UploadingState.Error -> { - accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) - } } - } catch (e: Exception) { - accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName)) - } finally { - isUploading = false - voiceOrchestrator = null + is UploadingState.Error -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + } + else -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + } } } fun cancel() { + cancelUpload() deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null @@ -236,6 +264,7 @@ class VoiceReplyViewModel : ViewModel() { } override fun onCleared() { + cancel() super.onCleared() Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") }