From 149990c57d3b3035dcdc25e0cbb24c35a9b0acf7 Mon Sep 17 00:00:00 2001 From: deepseven <5635954+deepseven@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:57:36 +0100 Subject: [PATCH] Improve recordings screen UX with expandable transcripts and multi-select Add tap-to-expand transcripts with selectable text, copy, and share buttons. The collapsed view shows a 3-line preview; expanding reveals the full text with selection support for easy copying. Add long-press multi-select mode with a selection toolbar showing the count of selected items. The toolbar provides bulk actions: copy all selected transcripts, transcribe selected recordings, and delete selected recordings. Add per-item transcribe button (pencil icon) that appears when transcription is configured. This allows re-transcribing individual recordings without going through the webhook flow. Cards are highlighted with primaryContainer color when selected and show checkboxes in selection mode. Action buttons (play, share, delete, webhook) are hidden during selection to reduce visual clutter. --- .../com/middle/app/ui/RecordingsScreen.kt | 345 ++++++++++++++---- .../app/viewmodel/RecordingsViewModel.kt | 53 +++ 2 files changed, 335 insertions(+), 63 deletions(-) diff --git a/android/app/src/main/java/com/middle/app/ui/RecordingsScreen.kt b/android/app/src/main/java/com/middle/app/ui/RecordingsScreen.kt index d150e0b..ff5d71a 100644 --- a/android/app/src/main/java/com/middle/app/ui/RecordingsScreen.kt +++ b/android/app/src/main/java/com/middle/app/ui/RecordingsScreen.kt @@ -1,7 +1,13 @@ package com.middle.app.ui +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.content.Intent -import androidx.compose.foundation.clickable +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,7 +20,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.PlayArrow @@ -22,6 +31,8 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -34,6 +45,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -62,6 +74,25 @@ fun RecordingsScreen( val syncState by SyncForegroundService.syncState.collectAsState() val batteryVoltage by SyncForegroundService.batteryVoltage.collectAsState() var showDeleteAllDialog by remember { mutableStateOf(false) } + var showDeleteSelectedDialog by remember { mutableStateOf(false) } + + // Selection mode state. + var selectionMode by remember { mutableStateOf(false) } + val selectedItems = remember { mutableStateListOf() } + + // Path of the recording whose transcript is expanded. + var expandedPath by remember { mutableStateOf(null) } + + val context = LocalContext.current + + // Prune stale selections when recordings change. + LaunchedEffect(recordings.size) { + if (selectedItems.isNotEmpty()) { + val paths = recordings.map { it.audioFile.absolutePath }.toSet() + selectedItems.removeAll { it !in paths } + if (selectedItems.isEmpty()) selectionMode = false + } + } if (showDeleteAllDialog) { AlertDialog( @@ -72,6 +103,8 @@ fun RecordingsScreen( TextButton( onClick = { viewModel.deleteAllRecordings() + selectionMode = false + selectedItems.clear() showDeleteAllDialog = false }, ) { @@ -86,23 +119,99 @@ fun RecordingsScreen( ) } + if (showDeleteSelectedDialog) { + val count = selectedItems.size + AlertDialog( + onDismissRequest = { showDeleteSelectedDialog = false }, + title = { Text("Delete $count recording${if (count != 1) "s" else ""}?") }, + text = { Text("The selected recording${if (count != 1) "s" else ""} and transcript${if (count != 1) "s" else ""} will be permanently deleted.") }, + confirmButton = { + TextButton( + onClick = { + val toDelete = recordings.filter { it.audioFile.absolutePath in selectedItems } + viewModel.deleteRecordings(toDelete) + selectedItems.clear() + selectionMode = false + showDeleteSelectedDialog = false + }, + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteSelectedDialog = false }) { + Text("Cancel") + } + }, + ) + } + Scaffold( topBar = { - TopAppBar( - title = { Text("Middle") }, - navigationIcon = { - IconButton(onClick = onOpenDrawer) { - Icon(Icons.Default.Menu, contentDescription = "Open menu") - } - }, - actions = { - if (recordings.isNotEmpty()) { - IconButton(onClick = { showDeleteAllDialog = true }) { - Icon(Icons.Default.Delete, contentDescription = "Delete all recordings") + if (selectionMode) { + TopAppBar( + title = { Text("${selectedItems.size} selected") }, + navigationIcon = { + IconButton(onClick = { + selectionMode = false + selectedItems.clear() + }) { + Icon(Icons.Default.Close, contentDescription = "Cancel selection") } - } - }, - ) + }, + actions = { + if (selectedItems.isNotEmpty()) { + // Copy transcripts for selected items. + TextButton(onClick = { + val transcripts = recordings + .filter { it.audioFile.absolutePath in selectedItems && it.hasTranscript } + .mapNotNull { it.transcriptText?.trim() } + .filter { it.isNotEmpty() } + if (transcripts.isEmpty()) { + Toast.makeText(context, "No transcripts to copy", Toast.LENGTH_SHORT).show() + } else { + val combined = transcripts.joinToString("\n\n") + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Transcripts", combined)) + Toast.makeText(context, "Copied ${transcripts.size} transcript(s)", Toast.LENGTH_SHORT).show() + } + }) { + Text("Copy") + } + // Transcribe selected items. + if (viewModel.transcriptionAvailable) { + IconButton(onClick = { + val toTranscribe = recordings.filter { it.audioFile.absolutePath in selectedItems } + viewModel.transcribeRecordings(toTranscribe) + selectionMode = false + selectedItems.clear() + }) { + Icon(Icons.Default.Create, contentDescription = "Transcribe selected") + } + } + IconButton(onClick = { showDeleteSelectedDialog = true }) { + Icon(Icons.Default.Delete, contentDescription = "Delete selected") + } + } + }, + ) + } else { + TopAppBar( + title = { Text("Middle") }, + navigationIcon = { + IconButton(onClick = onOpenDrawer) { + Icon(Icons.Default.Menu, contentDescription = "Open menu") + } + }, + actions = { + if (recordings.isNotEmpty()) { + IconButton(onClick = { showDeleteAllDialog = true }) { + Icon(Icons.Default.Delete, contentDescription = "Delete all recordings") + } + } + }, + ) + } }, ) { padding -> Column( @@ -157,7 +266,7 @@ fun RecordingsScreen( } else { val listState = rememberLazyListState() - // Scroll to top whenever the number of recordings changes (i.e. a new one was added). + // Scroll to top whenever the number of recordings changes. LaunchedEffect(recordings.size) { listState.animateScrollToItem(0) } @@ -167,13 +276,38 @@ fun RecordingsScreen( modifier = Modifier.fillMaxSize(), ) { items(recordings, key = { it.audioFile.absolutePath }) { recording -> + val path = recording.audioFile.absolutePath + val isSelected = path in selectedItems + val isExpanded = expandedPath == path + RecordingItem( recording = recording, isPlaying = currentlyPlaying == recording, + isExpanded = isExpanded, + selectionMode = selectionMode, + isSelected = isSelected, + onTap = { + if (selectionMode) { + if (isSelected) selectedItems.remove(path) + else selectedItems.add(path) + if (selectedItems.isEmpty()) selectionMode = false + } else { + expandedPath = if (isExpanded) null else path + } + }, + onLongPress = { + if (!selectionMode) { + selectionMode = true + selectedItems.clear() + selectedItems.add(path) + } + }, onTogglePlayback = { viewModel.togglePlayback(recording) }, onDelete = { viewModel.deleteRecording(recording) }, showResendWebhook = viewModel.webhookEnabled && (recording.hasTranscript || viewModel.transcriptionAvailable), onResendWebhook = { viewModel.sendWebhook(recording) }, + showTranscribe = viewModel.transcriptionAvailable, + onTranscribe = { viewModel.transcribeRecording(recording) }, ) } } @@ -182,14 +316,22 @@ fun RecordingsScreen( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun RecordingItem( recording: Recording, isPlaying: Boolean, + isExpanded: Boolean, + selectionMode: Boolean, + isSelected: Boolean, + onTap: () -> Unit, + onLongPress: () -> Unit, onTogglePlayback: () -> Unit, onDelete: () -> Unit, showResendWebhook: Boolean, onResendWebhook: () -> Unit, + showTranscribe: Boolean = false, + onTranscribe: () -> Unit = {}, ) { val context = LocalContext.current var showDeleteDialog by remember { mutableStateOf(false) } @@ -221,7 +363,17 @@ private fun RecordingItem( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp) - .clickable(onClick = onTogglePlayback), + .combinedClickable( + onClick = onTap, + onLongClick = onLongPress, + ), + colors = if (isSelected) { + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) + } else { + CardDefaults.cardColors() + }, ) { Column(modifier = Modifier.padding(16.dp)) { Row( @@ -229,6 +381,13 @@ private fun RecordingItem( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { + if (selectionMode) { + Checkbox( + checked = isSelected, + onCheckedChange = { onTap() }, + ) + } + Column(modifier = Modifier.weight(1f)) { Text( text = recording.timestamp.format(DISPLAY_FORMAT), @@ -240,67 +399,127 @@ private fun RecordingItem( ) } - IconButton(onClick = onTogglePlayback) { - if (isPlaying) { + if (!selectionMode) { + IconButton(onClick = onTogglePlayback) { + if (isPlaying) { + Icon( + painter = painterResource(R.drawable.ic_stop), + contentDescription = "Stop", + modifier = Modifier.size(24.dp), + ) + } else { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Play", + ) + } + } + + IconButton( + onClick = { + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + recording.audioFile, + ) + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "audio/mp4" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(shareIntent, "Share recording")) + }, + ) { Icon( - painter = painterResource(R.drawable.ic_stop), - contentDescription = "Stop", - modifier = Modifier.size(24.dp), + imageVector = Icons.Default.Share, + contentDescription = "Share audio", ) - } else { + } + + IconButton(onClick = { showDeleteDialog = true }) { Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = "Play", + imageVector = Icons.Default.Delete, + contentDescription = "Delete", ) } - } - IconButton( - onClick = { - val uri = FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - recording.audioFile, - ) - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "audio/mp4" - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (showTranscribe) { + IconButton(onClick = onTranscribe) { + Icon( + imageVector = Icons.Default.Create, + contentDescription = "Transcribe", + ) } - context.startActivity(Intent.createChooser(shareIntent, "Share recording")) - }, - ) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = "Share", - ) + } + + if (showResendWebhook) { + IconButton(onClick = onResendWebhook) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Resend webhook", + ) + } + } } + } - IconButton(onClick = { showDeleteDialog = true }) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Delete", + // Transcript section. + if (recording.hasTranscript) { + val transcript = recording.transcriptText ?: "" + + if (!isExpanded) { + // Collapsed: show 3-line preview. + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = transcript, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, ) } - if (showResendWebhook) { - IconButton(onClick = onResendWebhook) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = "Resend webhook", - ) + AnimatedVisibility(visible = isExpanded) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + + // Full selectable transcript text. + SelectionContainer { + Text( + text = transcript, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Action buttons for the transcript. + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Transcript", transcript)) + Toast.makeText(context, "Transcript copied", Toast.LENGTH_SHORT).show() + }, + ) { + Text("Copy", style = MaterialTheme.typography.labelMedium) + } + + TextButton( + onClick = { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, transcript) + } + context.startActivity(Intent.createChooser(shareIntent, "Share transcript")) + }, + ) { + Text("Share text", style = MaterialTheme.typography.labelMedium) + } + } } } } - - if (recording.hasTranscript) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = recording.transcriptText ?: "", - style = MaterialTheme.typography.bodyMedium, - maxLines = 3, - ) - } } } } diff --git a/android/app/src/main/java/com/middle/app/viewmodel/RecordingsViewModel.kt b/android/app/src/main/java/com/middle/app/viewmodel/RecordingsViewModel.kt index 8133cda..1ef80d6 100644 --- a/android/app/src/main/java/com/middle/app/viewmodel/RecordingsViewModel.kt +++ b/android/app/src/main/java/com/middle/app/viewmodel/RecordingsViewModel.kt @@ -127,6 +127,49 @@ class RecordingsViewModel(application: Application) : AndroidViewModel(applicati } } + fun transcribeRecording(recording: Recording) { + viewModelScope.launch(Dispatchers.IO) { + val provider = settings.transcriptionProvider + val apiKey = getSelectedProviderApiKey() + val client = TranscriptionClient(provider, apiKey) + showToast("Transcribing ${recording.audioFile.name}…") + try { + val text = client.transcribe(recording.audioFile) + if (text != null) { + repository.saveTranscript(text, recording.audioFile) + showToast("Transcription complete") + } else { + showToast("Transcription failed (${providerDisplayName(provider)})") + } + } catch (exception: Exception) { + Log.e(TAG, "Transcription failed for ${recording.audioFile.name}: $exception") + showToast("Transcription error: ${exception.message}") + } + } + } + + fun transcribeRecordings(toTranscribe: List) { + viewModelScope.launch(Dispatchers.IO) { + val provider = settings.transcriptionProvider + val apiKey = getSelectedProviderApiKey() + val client = TranscriptionClient(provider, apiKey) + showToast("Transcribing ${toTranscribe.size} recording(s)…") + var successCount = 0 + for (recording in toTranscribe) { + try { + val text = client.transcribe(recording.audioFile) + if (text != null) { + repository.saveTranscript(text, recording.audioFile) + successCount++ + } + } catch (exception: Exception) { + Log.e(TAG, "Transcription failed for ${recording.audioFile.name}: $exception") + } + } + showToast("Transcribed $successCount of ${toTranscribe.size}") + } + } + fun deleteRecording(recording: Recording) { if (_currentlyPlaying.value == recording) { stopPlayback() @@ -137,6 +180,16 @@ class RecordingsViewModel(application: Application) : AndroidViewModel(applicati } } + fun deleteRecordings(toDelete: List) { + toDelete.forEach { if (_currentlyPlaying.value == it) stopPlayback() } + viewModelScope.launch(Dispatchers.IO) { + toDelete.forEach { + retryQueue.removeForRecording(it.audioFile.name) + repository.deleteRecording(it) + } + } + } + fun deleteAllRecordings() { stopPlayback() val currentRecordings = recordings.value