fix(ui): resolve build errors in MainScreen

This commit is contained in:
dave 2025-04-08 17:40:57 +03:00
parent c22a20b2c8
commit 3bcbda4d27
2 changed files with 280 additions and 174 deletions
app/src/main/java/com/voca/app

@ -381,45 +381,37 @@ fun MainScreen(
Spacer(modifier = Modifier.height(16.dp))
// --- Conditionally Render Controls ---
// --- Use Unified Controls ---
UnifiedPlaybackControls(
ttsState = ttsState,
playbackState = playbackState,
isContentAvailable = isDocumentLoaded || hasUserInput,
isMp3Mode = isMp3FlowActive, // Determine if MP3 mode is active
onTogglePlayback = { viewModel.onAction(MainAction.TogglePlayback) }, // Pass the new action
onStopPlayback = { viewModel.onAction(MainAction.StopPlayback) } // Pass the new action
)
// --- End Unified Controls ---
// --- MP3 Generation/Save/Seek UI (Keep separate for now) ---
if (isMp3FlowActive) {
// MP3 Player Controls - Only show if MP3 flow is active
Timber.d("Rendering MP3 Controls because isMp3FlowActive = true")
Mp3PlayerControls(
playbackState = playbackState,
mp3GenerationState = mp3GenerationState,
mp3SaveState = mp3SaveState,
currentMp3UriFromViewModel = currentMp3Uri,
// Enable generation button only if there's content and MP3 isn't already active/generating
generationEnabled = (isDocumentLoaded || hasUserInput) && !(playbackState.currentUri != null || mp3GenerationState is Mp3GenerationProgress.InProgress),
onPlay = { (viewModel::onAction)(MainAction.PlayMp3) },
onPause = { (viewModel::onAction)(MainAction.PauseMp3) },
onResume = { (viewModel::onAction)(MainAction.ResumeMp3) },
onStop = { (viewModel::onAction)(MainAction.StopMp3) },
onCancel = { (viewModel::onAction)(MainAction.CancelMp3Generation) },
onSeek = { positionMs -> (viewModel::onAction)(MainAction.SeekMp3(positionMs)) },
onGenerateMp3 = { (viewModel::onAction)(MainAction.GenerateMp3) }
)
} else {
// TTS Playback Controls - Only show if MP3 flow is NOT active
Timber.d("Rendering TTS Controls because isMp3FlowActive = false")
TtsControls(
ttsState = ttsState,
// Enable TTS controls only if there is content available
enabled = isDocumentLoaded || hasUserInput,
onPlayPauseToggle = {
if (ttsState.isSpeaking && !ttsState.isPaused) {
(viewModel::onAction)(MainAction.PauseSpeech)
} else if (ttsState.isPaused) {
(viewModel::onAction)(MainAction.ResumeSpeech)
} else {
(viewModel::onAction)(MainAction.PlayText) // ViewModel will decide source
}
},
onStop = { (viewModel::onAction)(MainAction.StopSpeaking) }
)
Mp3GenerationAndSeekControls(
playbackState = playbackState,
mp3GenerationState = mp3GenerationState,
mp3SaveState = mp3SaveState,
currentMp3UriFromViewModel = currentMp3Uri,
onCancel = { (viewModel::onAction)(MainAction.CancelMp3Generation) },
onSeek = { positionMs -> (viewModel::onAction)(MainAction.SeekMp3(positionMs)) },
onGenerateMp3 = { (viewModel::onAction)(MainAction.GenerateMp3) }
)
} else if (isDocumentLoaded || hasUserInput) { // Use the same condition used for `generationEnabled` previously
Button(
onClick = { viewModel.onAction(MainAction.GenerateMp3) },
modifier = Modifier.padding(top = 8.dp) // Add some padding
) {
Text("Create MP3")
}
}
// ---
// --- End MP3 Generation/Save/Seek UI ---
// Optional: Show subtle indicator if background audio is playing (Consider removing if controls are clear)
/*
@ -438,38 +430,58 @@ fun MainScreen(
}
}
// Simple composable for TTS control buttons
// --- NEW Unified Playback Controls Composable ---
@Composable
fun TtsControls(
fun UnifiedPlaybackControls(
ttsState: TTSState,
enabled: Boolean,
onPlayPauseToggle: () -> Unit,
onStop: () -> Unit
playbackState: PlaybackState,
isContentAvailable: Boolean,
isMp3Mode: Boolean, // True if MP3 is loaded/active, false for TTS
onTogglePlayback: () -> Unit,
onStopPlayback: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
// Determine Play/Pause state based on mode
val isPlaying = if (isMp3Mode) playbackState.isPlaying else ttsState.isSpeaking && !ttsState.isPaused
val isPaused = if (isMp3Mode) playbackState.isPaused else ttsState.isPaused
val canPlayback = isContentAvailable || (isMp3Mode && playbackState.currentUri != null) // Enable if content or MP3 URI exists
// Play/Pause Button
IconButton(onClick = onPlayPauseToggle, enabled = enabled) {
IconButton(onClick = onTogglePlayback, enabled = canPlayback) {
Icon(
imageVector = if (ttsState.isSpeaking && !ttsState.isPaused) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (ttsState.isSpeaking && !ttsState.isPaused) "Pause Speech" else "Play Speech",
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = when {
isPlaying -> "Pause"
isPaused -> "Resume"
else -> "Play"
},
modifier = Modifier.size(48.dp)
)
}
// Stop Button (Enabled only if speaking or paused)
IconButton(onClick = onStop, enabled = ttsState.isSpeaking || ttsState.isPaused) {
// Stop Button (Enabled if either TTS or MP3 is playing or paused)
val canStop = (isMp3Mode && (playbackState.isPlaying || playbackState.isPaused)) ||
(!isMp3Mode && (ttsState.isSpeaking || ttsState.isPaused))
IconButton(onClick = onStopPlayback, enabled = canStop) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = "Stop Speech",
contentDescription = "Stop",
modifier = Modifier.size(48.dp)
)
}
}
}
// --- END Unified Playback Controls Composable ---
// Simple composable for TTS control buttons - REMOVED
/*
@Composable
fun TtsControls(...) { ... }
*/
// --- Preview Data Structure ---
// Simple data class to hold state for preview purposes, avoiding complex ViewModel mocking
@ -546,17 +558,14 @@ fun MainScreenPreviewDark() {
Spacer(modifier = Modifier.height(16.dp))
}
TtsControls(
enabled = true,
ttsState = ttsState,
onPlayPauseToggle = {
if (ttsState.isSpeaking && !ttsState.isPaused) {
state.onAction(MainAction.PauseSpeech)
} else {
state.onAction(MainAction.PlayText)
}
},
onStop = { state.onAction(MainAction.StopSpeaking) }
// Use UnifiedPlaybackControls in Preview
UnifiedPlaybackControls(
ttsState = ttsState, // Pass dummy state
playbackState = PlaybackState(), // Pass dummy state
isContentAvailable = text.isNotEmpty(),
isMp3Mode = false, // Assume TTS mode for preview
onTogglePlayback = { state.onAction(MainAction.TogglePlayback) }, // Use new action
onStopPlayback = { state.onAction(MainAction.StopPlayback) } // Use new action
)
when(genState) {
@ -776,15 +785,6 @@ fun Mp3PlayerControls(
}
}
/**
* Formats milliseconds duration into MM:SS format.
*/
private fun formatDuration(millis: Long): String {
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis)
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
return String.format("%02d:%02d", minutes, seconds)
}
/**
* New Composable for displaying the full text with sentence highlighting.
*/
@ -936,4 +936,142 @@ fun FullTextDisplayArea(
)
}
}
}
}
// --- NEW Composable for MP3 Generation and Seek Controls ---
@Composable
fun Mp3GenerationAndSeekControls(
playbackState: PlaybackState,
mp3GenerationState: Mp3GenerationProgress?,
mp3SaveState: FileSaveProgress?,
currentMp3UriFromViewModel: Uri?, // Keep for context, might be redundant
onCancel: () -> Unit,
onSeek: (Long) -> Unit,
onGenerateMp3: () -> Unit // Can likely remove this if generate button is outside
) {
val isGenerating = mp3GenerationState is Mp3GenerationProgress.InProgress
val isSaving = mp3SaveState is FileSaveProgress.InProgress
val isProcessing = isGenerating || isSaving // Combine generation and saving flags
// Ready ONLY if NOT processing AND (saving succeeded OR playback service has a URI loaded)
val isMp3Ready = !isProcessing &&
(mp3SaveState is FileSaveProgress.Success || playbackState.currentUri != null)
val hasError = !isProcessing && ( // Only show errors *after* processing finishes
playbackState.error != null ||
mp3GenerationState is Mp3GenerationProgress.Error ||
mp3SaveState is FileSaveProgress.Error
)
val isLoading = !isProcessing && !isMp3Ready && !hasError && playbackState.isLoading
// Determine overall visibility: Show if processing, ready, loading, or has error
val shouldShowSection = isProcessing || isMp3Ready || isLoading || hasError
if (shouldShowSection) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// --- Show Progress Indicator Section (Highest Priority) ---
if (isProcessing) {
val progress: Float = when {
isGenerating -> (mp3GenerationState as Mp3GenerationProgress.InProgress).progress
isSaving -> 0.5f // Indicate saving is happening, maybe use indeterminate later
else -> 0f
}
val progressText: String = when {
isGenerating -> "Creating MP3..."
isSaving -> "Saving MP3..."
else -> "Processing..."
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(progressText, style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = { progress }, // Use calculated progress
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
// Add an indeterminate bar if progress is low or saving
if (progress <= 0.01f || isSaving) {
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.secondary
)
}
}
Spacer(modifier = Modifier.width(8.dp))
// Show cancel button only during generation, not saving
if (isGenerating) {
Button(
onClick = onCancel,
enabled = mp3GenerationState is Mp3GenerationProgress.InProgress,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) {
Text("X")
}
}
}
// --- Show Seek Bar Section (Only if Ready and has duration) ---
} else if (isMp3Ready && playbackState.durationMs > 0) {
// Seek Bar and Time Display
Slider(
value = playbackState.currentPositionMs.toFloat(),
onValueChange = { onSeek(it.roundToLong()) },
valueRange = 0f..playbackState.durationMs.toFloat(),
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(formatDuration(playbackState.currentPositionMs), style = MaterialTheme.typography.labelSmall)
Text(formatDuration(playbackState.durationMs), style = MaterialTheme.typography.labelSmall)
}
// --- Show Loading Indicator Section (Only if Loading) ---
} else if (isLoading) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.size(8.dp))
Text("Loading MP3...", style = MaterialTheme.typography.bodyMedium)
}
// --- Show Error Section (Only if not processing, not ready, not loading, but has error) ---
} else if (hasError) {
val errorMsg = when {
mp3GenerationState is Mp3GenerationProgress.Error -> "Error generating MP3: ${mp3GenerationState.message}"
mp3SaveState is FileSaveProgress.Error -> "Error saving MP3: ${mp3SaveState.message}"
playbackState.error != null -> "Error loading MP3: ${playbackState.error}"
else -> "An unknown error occurred."
}
Text(
errorMsg,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
// --- END MP3 Generation and Seek Controls Composable ---
/**
* Formats milliseconds duration into MM:SS format.
*/
private fun formatDuration(millis: Long): String {
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis)
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
return String.format("%02d:%02d", minutes, seconds)
}
// ... existing code ...

@ -63,20 +63,31 @@ sealed interface MainAction {
data class ProcessSelectedDocument(val uri: Uri) : MainAction
data class ProcessUri(val uri: Uri) : MainAction
data class ProcessSharedText(val text: String) : MainAction
data object PlayText : MainAction // Plays current page via TTS
data object PauseSpeech : MainAction
data object ResumeSpeech : MainAction
data object StopSpeaking : MainAction
data class SetTextAndPlay(val text: String) : MainAction // Set text and immediately play TTS
data class SetTextAndPlay(val text: String) : MainAction // Set text and immediately play TTS - Keep for specific use cases
// --- NEW Unified Playback Actions ---
data object TogglePlayback : MainAction // Unified Play/Pause/Resume
data object StopPlayback : MainAction // Unified Stop
// --- END Unified Actions ---
// --- REMOVED Specific Playback Actions ---
// data object PlayText : MainAction
// data object PauseSpeech : MainAction
// data object ResumeSpeech : MainAction
// data object StopSpeaking : MainAction
// data object PlayMp3 : MainAction
// data object PauseMp3 : MainAction
// data object ResumeMp3 : MainAction
// data object StopMp3 : MainAction
// --- END REMOVED ---
data object GenerateMp3 : MainAction
data object CancelMp3Generation : MainAction // New action to cancel MP3 generation
data class SaveMp3(val desiredFileName: String? = null) : MainAction // Optional desired filename
data object PlayMp3 : MainAction // Plays the last generated/loaded MP3
data object PauseMp3 : MainAction
data object ResumeMp3 : MainAction
data object StopMp3 : MainAction
// Change parameter type to Long
// Keep Seek action for MP3
data class SeekMp3(val positionMs: Long) : MainAction
data object NextPage : MainAction
data object PreviousPage : MainAction
data object TriggerDocumentPicker : MainAction
@ -332,18 +343,16 @@ class MainViewModel(
is MainAction.ProcessUri -> handleLoadDocumentAndInitiateAudioProcessing(action.uri)
is MainAction.ProcessSharedText -> handleUpdateText(action.text)
is MainAction.UpdateText -> handleUpdateText(action.text)
is MainAction.SetTextAndPlay -> handleSetTextAndPlay(action.text) // Processes text then speaks
is MainAction.SetTextAndPlay -> handleSetTextAndPlay(action.text)
is MainAction.UpdateInputText -> {
_userInputText.value = action.text
}
is MainAction.TriggerDocumentPicker -> handleTriggerDocumentPicker()
// TTS Actions
MainAction.PlayText -> handlePlayText() // Plays current page
MainAction.PauseSpeech -> handlePauseSpeech()
MainAction.ResumeSpeech -> handleResumeSpeech()
MainAction.StopSpeaking -> handleStopSpeaking()
// MainAction.ToggleTTS -> handleToggleTTS() // Removed as separate Pause/Resume/Play are preferred
// --- UPDATED Playback Actions ---
MainAction.TogglePlayback -> handleTogglePlayback()
MainAction.StopPlayback -> handleStopPlayback()
// --- END UPDATED ---
// MP3 Generation & Saving (Now combined or manual)
MainAction.GenerateMp3 -> handleGenerateAndSaveMp3() // Manual trigger for combined process
@ -351,10 +360,6 @@ class MainViewModel(
is MainAction.SaveMp3 -> Unit // Explicitly ignore if saving is purely automatic now
// MP3 Playback
MainAction.PlayMp3 -> handlePlayMp3()
MainAction.PauseMp3 -> handlePauseMp3()
MainAction.ResumeMp3 -> handleResumeMp3()
MainAction.StopMp3 -> handleStopMp3()
is MainAction.SeekMp3 -> handleSeekMp3(action.positionMs)
// Pagination
@ -519,41 +524,54 @@ class MainViewModel(
}
}
private fun handlePlayText() {
Timber.d("handlePlayText called.")
// --- NEW Handler for Unified Playback Toggle ---
private fun handleTogglePlayback() {
Timber.d("handleTogglePlayback called.")
viewModelScope.launch {
// --- ADD CHECK: Do not start TTS if an MP3 is loaded ---
if (_currentMp3Uri.value != null) {
Timber.w("handlePlayText: MP3 URI is loaded ($_currentMp3Uri.value). Skipping TTS playback.")
return@launch
}
// --- END CHECK ---
val mp3Uri = _currentMp3Uri.value
val currentPlaybackState = playbackState.value
val currentTtsState = ttsStateFlow.value
val textToSpeak = getCurrentDocumentStateUseCase.currentPageText.value
if (!textToSpeak.isNullOrBlank()) {
Timber.d("Speaking current page text: '${textToSpeak.take(50)}...'")
speakTextUseCase(textToSpeak)
if (mp3Uri != null) {
// --- MP3 Mode ---
Timber.d("Toggle playback in MP3 mode. State: $currentPlaybackState")
when {
currentPlaybackState.isPlaying -> pauseMp3UseCase()
currentPlaybackState.isPaused -> resumeMp3UseCase()
else -> playMp3UseCase(mp3Uri) // Start playback if idle
}
} else {
Timber.w("PlayText action called but no text available (document or input).")
// --- TTS Mode ---
Timber.d("Toggle playback in TTS mode. State: $currentTtsState")
val textToSpeak = getCurrentDocumentStateUseCase.currentPageText.value
if (!textToSpeak.isNullOrBlank()) {
when {
currentTtsState.isSpeaking && !currentTtsState.isPaused -> pauseTTSUseCase()
currentTtsState.isPaused -> resumeTTSUseCase()
else -> speakTextUseCase(textToSpeak) // Start TTS if idle
}
} else {
Timber.w("TogglePlayback (TTS): No text available.")
// Optionally emit an error or show a message
}
}
}
}
// --- END Handler ---
private fun handlePauseSpeech() {
Timber.d("handlePauseSpeech called.")
pauseTTSUseCase()
}
private fun handleResumeSpeech() {
Timber.d("handleResumeSpeech called.")
resumeTTSUseCase()
}
private fun handleStopSpeaking() {
Timber.d("handleStopSpeaking called.")
stopTTSUseCase()
// --- NEW Handler for Unified Stop ---
private fun handleStopPlayback() {
Timber.d("handleStopPlayback called.")
viewModelScope.launch {
// Stop both TTS and MP3 playback regardless of the current mode
Timber.d("Stopping both TTS and MP3.")
stopTTSUseCase()
stopMp3UseCase()
}
}
// --- END Handler ---
// --- ADDED BACK: Handler for SetTextAndPlay ---
private fun handleSetTextAndPlay(text: String) {
Timber.d("handleSetTextAndPlay called.")
if (text.isBlank()) {
@ -566,17 +584,20 @@ class MainViewModel(
resetStateForNewContent() // Reset state FIRST
delay(50) // Small delay to allow state reset propagation if needed
_mp3SaveProgress.value = null // Reset save state
_isLoading.value = true
_processingStatus.value = PdfProcessingStatus.PROCESSING_TEXT
try {
processDocumentUseCase.processText(text)
delay(50) // Keep delay for now, but ideally improve state propagation
val updatedText = getCurrentDocumentStateUseCase.currentPageText.value
if (!updatedText.isNullOrBlank()) {
speakTextUseCase(updatedText)
_processingStatus.value = PdfProcessingStatus.COMPLETED
// Initiate TTS playback after processing
speakTextUseCase(updatedText)
_processingStatus.value = PdfProcessingStatus.COMPLETED // Mark as completed after starting TTS
} else {
Timber.w("handleSetTextAndPlay: Text set, but state not updated in time for TTS.")
_processingStatus.value = PdfProcessingStatus.COMPLETED
_processingStatus.value = PdfProcessingStatus.COMPLETED // Mark as completed even if TTS didn't start
}
} catch (e: Exception) {
Timber.e(e, "Error in setTextAndPlay")
@ -586,6 +607,7 @@ class MainViewModel(
}
}
}
// --- END ADDED BACK ---
private fun handleGenerateAndSaveMp3() {
Timber.d("handleGenerateAndSaveMp3 called. Adding debug checkpoints.")
@ -818,60 +840,6 @@ class MainViewModel(
}
}
private fun handlePlayMp3(mp3Uri: Uri? = null) {
Timber.d("handlePlayMp3 called.")
viewModelScope.launch {
// Use the _currentMp3Uri state if no explicit URI is passed
val uriToPlay = mp3Uri ?: _currentMp3Uri.value
if (uriToPlay != null) {
try {
Timber.d("Attempting to play MP3 from URI: $uriToPlay")
Timber.i("--> [VM] handlePlayMp3: Calling playMp3UseCase for URI: $uriToPlay")
// Call use case with the correct URI
playMp3UseCase(uriToPlay)
} catch (e: Exception) {
Timber.e(e, "Error starting MP3 playback for URI: $uriToPlay")
}
} else {
Timber.w("Play MP3 requested, but no URI available.")
}
}
}
private fun handlePauseMp3() {
Timber.d("handlePauseMp3 called.")
viewModelScope.launch {
try {
pauseMp3UseCase()
} catch (e: Exception) {
Timber.e(e, "Error pausing MP3 playback")
}
}
}
private fun handleResumeMp3() {
Timber.d("handleResumeMp3 called.")
viewModelScope.launch {
try {
resumeMp3UseCase()
} catch (e: Exception) {
Timber.e(e, "Error resuming MP3 playback")
}
}
}
private fun handleStopMp3() {
Timber.d("handleStopMp3 called.")
viewModelScope.launch {
try {
stopMp3UseCase()
} catch (e: Exception) {
Timber.e(e, "Error stopping MP3 playback")
}
}
}
private fun handleSeekMp3(positionMs: Long) {
Timber.d("handleSeekMp3 called with position: $positionMs ms")
// No need for viewModelScope for simple seek