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