// Common.kt @file:OptIn(ExperimentalMaterial3Api::class) package com.aryan.reader import android.content.Context import android.speech.tts.TextToSpeech import android.speech.tts.Voice import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.drag 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 import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropUp import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Smartphone import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.PlainTooltip import androidx.compose.material3.RichTooltip import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageShader import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.core.content.edit import androidx.core.graphics.toColorInt import androidx.media3.common.util.UnstableApi import com.aryan.reader.epubreader.PREF_CUSTOM_THEMES import com.aryan.reader.epubreader.PREF_READER_THEME import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.tts.GOOGLE_TTS_SPEAKERS import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.splitTextIntoChunks import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.commonmark.node.AbstractVisitor import org.commonmark.node.Code import org.commonmark.node.Emphasis import org.commonmark.node.HardLineBreak import org.commonmark.node.Heading import org.commonmark.node.ListItem import org.commonmark.node.Paragraph import org.commonmark.node.SoftLineBreak import org.commonmark.node.StrongEmphasis import org.commonmark.node.Text import org.commonmark.parser.Parser import org.json.JSONArray import org.json.JSONObject import timber.log.Timber import java.io.File import java.net.HttpURLConnection import java.net.URL import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt const val aiServerBasePath = BuildConfig.AI_WORKER_URL const val summarizeEndpoint = "/summarize" const val summarizationUrl = aiServerBasePath + summarizeEndpoint const val defineEndpoint = "/define" const val aiDefinitionUrl = aiServerBasePath + defineEndpoint const val recapEndpoint = "/recap" const val recapUrl = aiServerBasePath + recapEndpoint const val PREF_NATIVE_TTS_VOICE = "native_tts_voice_name" data class SearchResult( val locationInSource: Int, val locationTitle: String, val snippet: AnnotatedString, val query: String, val occurrenceIndexInLocation: Int, val chunkIndex: Int ) data class AiDefinitionResult( val definition: String? = null, val error: String? = null ) data class SummarizationResult( val summary: String? = null, val error: String? = null ) @Stable class SearchState( private val scope: CoroutineScope, private val searcher: suspend (String) -> List ) { var isSearchActive by mutableStateOf(false) var showSearchResultsPanel by mutableStateOf(true) var searchQuery by mutableStateOf("") var searchResults by mutableStateOf>(emptyList()) var isSearchInProgress by mutableStateOf(false) var currentSearchResultIndex by mutableIntStateOf(-1) val searchResultsCount by derivedStateOf { searchResults.size } val hasResults by derivedStateOf { searchResults.isNotEmpty() } private var searchJob: Job? = null fun onQueryChange(newQuery: String) { searchQuery = newQuery searchJob?.cancel() searchJob = scope.launch { if (newQuery.isBlank()) { searchResults = emptyList() currentSearchResultIndex = -1 isSearchInProgress = false return@launch } delay(350) showSearchResultsPanel = true isSearchInProgress = true currentSearchResultIndex = -1 searchResults = searcher(newQuery) isSearchInProgress = false } } fun forceSearch() { searchJob?.cancel() searchJob = scope.launch { if (searchQuery.isBlank()) { searchResults = emptyList() currentSearchResultIndex = -1 isSearchInProgress = false return@launch } showSearchResultsPanel = true isSearchInProgress = true currentSearchResultIndex = -1 searchResults = searcher(searchQuery) isSearchInProgress = false } } } @Composable fun rememberSearchState( scope: CoroutineScope, searcher: suspend (String) -> List ): SearchState { return remember { SearchState(scope, searcher) } } private val activeTooltipState = mutableStateOf(null) @OptIn(ExperimentalMaterial3Api::class) @Composable fun TooltipIconButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, description: String? = null, content: @Composable () -> Unit ) { val tooltipState = rememberTooltipState(isPersistent = true) val scope = rememberCoroutineScope() LaunchedEffect(tooltipState.isVisible) { if (tooltipState.isVisible) { val previous = activeTooltipState.value if (previous != null && previous !== tooltipState) { previous.dismiss() } activeTooltipState.value = tooltipState } else { if (activeTooltipState.value === tooltipState) { activeTooltipState.value = null } } } TooltipBox( positionProvider = if (description != null) TooltipDefaults.rememberRichTooltipPositionProvider() else TooltipDefaults.rememberPlainTooltipPositionProvider(), tooltip = { if (description != null) { RichTooltip( title = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { content() Text( text = text, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold ) } }, colors = TooltipDefaults.richTooltipColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, titleContentColor = MaterialTheme.colorScheme.onSurface ) ) { Text( text = description, style = MaterialTheme.typography.bodySmall ) } } else { PlainTooltip { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { content() Text(text) } } } }, state = tooltipState ) { IconButton( onClick = onClick, modifier = modifier, enabled = enabled ) { content() } } } @Composable fun SearchTopBar( searchState: SearchState, focusRequester: FocusRequester, onCloseSearch: () -> Unit ) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current Surface( modifier = Modifier .fillMaxWidth() .height(55.dp), color = MaterialTheme.colorScheme.surface, tonalElevation = 4.dp ) { Row( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)) .padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically ) { TooltipIconButton( text = stringResource(R.string.tooltip_close_search), description = stringResource(R.string.tooltip_close_search_desc), onClick = onCloseSearch ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_desc_close_search) ) } TextField( value = searchState.searchQuery, onValueChange = { searchState.onQueryChange(it) }, placeholder = { Text(stringResource(R.string.search_in_book)) }, modifier = Modifier .weight(1f) .focusRequester(focusRequester) .testTag("SearchTextField"), singleLine = true, colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { searchState.forceSearch() keyboardController?.hide() focusManager.clearFocus() }) ) if (searchState.searchQuery.isNotEmpty()) { TooltipIconButton( text = stringResource(R.string.tooltip_clear_search), description = stringResource(R.string.tooltip_clear_search_desc), onClick = { searchState.onQueryChange("") } ) { Icon( Icons.Default.Close, contentDescription = stringResource(R.string.content_desc_clear_search) ) } } TooltipIconButton( text = if (searchState.showSearchResultsPanel) stringResource(R.string.tooltip_hide_results) else stringResource(R.string.tooltip_show_results), description = if (searchState.showSearchResultsPanel) stringResource(R.string.tooltip_hide_results_desc) else stringResource(R.string.tooltip_show_results_desc), onClick = { searchState.showSearchResultsPanel = !searchState.showSearchResultsPanel focusManager.clearFocus() } ) { Icon( imageVector = if (searchState.showSearchResultsPanel) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, contentDescription = stringResource( if (searchState.showSearchResultsPanel) R.string.content_desc_hide_results else R.string.content_desc_show_results ) ) } } } } @Composable fun SearchNavigationControls( searchState: SearchState, onNavigate: (Int) -> Unit ) { Surface( shape = MaterialTheme.shapes.medium, tonalElevation = 6.dp, color = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurfaceVariant ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp) ) { TooltipIconButton( text = stringResource(R.string.tooltip_prev_result), description = stringResource(R.string.tooltip_prev_result_desc), onClick = { onNavigate(searchState.currentSearchResultIndex - 1) }, enabled = searchState.currentSearchResultIndex > 0 ) { Icon(Icons.Default.ArrowDropUp, contentDescription = stringResource(R.string.content_desc_prev_result)) } Text( text = "${searchState.currentSearchResultIndex + 1}/${searchState.searchResultsCount}", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 4.dp) ) TooltipIconButton( text = stringResource(R.string.tooltip_next_result), description = stringResource(R.string.tooltip_next_result_desc), onClick = { onNavigate(searchState.currentSearchResultIndex + 1) }, enabled = searchState.currentSearchResultIndex < searchState.searchResultsCount - 1 ) { Icon(Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.content_desc_next_result)) } } } } @androidx.annotation.OptIn(UnstableApi::class) @Composable fun SummarizationPopup( title: String, result: SummarizationResult?, isLoading: Boolean, onDismiss: () -> Unit, isMainTtsActive: Boolean = false, ) { val ttsController = rememberTtsController() val ttsState by ttsController.ttsState.collectAsState() val context = LocalContext.current LocalContext.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current val scope = rememberCoroutineScope() DisposableEffect(Unit) { onDispose { if (ttsState.playbackSource == "POPUP" && (ttsState.isPlaying || ttsState.isLoading)) { ttsController.stop() } } } Popup( alignment = Alignment.Center, onDismissRequest = onDismiss, properties = PopupProperties(focusable = true) ) { Card( modifier = Modifier .fillMaxWidth(0.9f) .padding(horizontal = 16.dp, vertical = 5.dp) .heightIn(min = 150.dp, max = 500.dp), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) ) { Column(modifier = Modifier.padding(all = 20.dp)) { Text( text = title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp) ) if (isLoading && (result?.summary.isNullOrBlank() && result?.error.isNullOrBlank())) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 24.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { CircularProgressIndicator() Text(stringResource(R.string.generating_summary), modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge) } } else if (result != null) { val summaryText = result.summary val errorText = result.error val styledContent = remember(summaryText, errorText) { if (!summaryText.isNullOrBlank()) { MarkdownParser.parse(summaryText) } else { AnnotatedString(errorText ?: "") } } val textToUse = styledContent.text if (textToUse.isNotBlank()) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { val isTtsSessionActive = ttsState.currentText != null || ttsState.isLoading IconButton( onClick = { if (isTtsSessionActive) { ttsController.stop() } else { val chunks = splitTextIntoChunks(textToUse).map { TtsChunk(it, "", -1) } if (chunks.isNotEmpty()) { ttsController.start( chunks = chunks, bookTitle = title, chapterTitle = "Summary", coverImageUri = null, ttsMode = loadTtsMode(context), playbackSource = "POPUP" ) } } }, enabled = !isMainTtsActive || (ttsState.playbackSource == "POPUP") ) { Icon( imageVector = if (isTtsSessionActive) Icons.Default.Stop else Icons.Default.PlayArrow, contentDescription = stringResource(if (isTtsSessionActive) R.string.action_stop else R.string.action_read_aloud) ) } Spacer(modifier = Modifier.width(8.dp)) IconButton(onClick = { clipboardManager.setText(AnnotatedString(textToUse)) }) { Icon( imageVector = Icons.Default.ContentCopy, contentDescription = stringResource(R.string.action_copy) ) } } } HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) if (errorText != null && summaryText.isNullOrBlank()) { Text(errorText, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyLarge) } else if (textToUse.isNotBlank()) { val scrollState = rememberScrollState() var textLayoutResult by remember { mutableStateOf(null) } LaunchedEffect(ttsState.currentText, textLayoutResult) { val currentChunk = ttsState.currentText val layoutResult = textLayoutResult if (!currentChunk.isNullOrBlank() && layoutResult != null) { val startIndex = textToUse.indexOf(currentChunk) if (startIndex != -1) { val line = layoutResult.getLineForOffset(startIndex) val lineTop = layoutResult.getLineTop(line) val viewportHeight = scrollState.viewportSize val targetScroll = (lineTop - viewportHeight / 2).coerceAtLeast(0f) scope.launch { scrollState.animateScrollTo(targetScroll.toInt()) } } } } val annotatedText = buildAnnotatedString { append(styledContent) val currentChunk = ttsState.currentText if (!currentChunk.isNullOrBlank()) { val startIndex = textToUse.indexOf(currentChunk) if (startIndex != -1) { addStyle( style = SpanStyle(background = MaterialTheme.colorScheme.primaryContainer), start = startIndex, end = startIndex + currentChunk.length ) } } } Text( text = annotatedText, modifier = Modifier.verticalScroll(scrollState), onTextLayout = { textLayoutResult = it } ) } else { Text(stringResource(R.string.no_summary_generated), style = MaterialTheme.typography.bodyLarge) } } } } } } @androidx.annotation.OptIn(UnstableApi::class) @Composable fun AiDefinitionPopup( word: String?, result: AiDefinitionResult?, isLoading: Boolean, onDismiss: () -> Unit, isMainTtsActive: Boolean = false, onOpenExternalDictionary: () -> Unit ) { val ttsController = rememberTtsController() val ttsState by ttsController.ttsState.collectAsState() val context = LocalContext.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current val scope = rememberCoroutineScope() DisposableEffect(Unit) { onDispose { if (ttsState.playbackSource == "POPUP" && (ttsState.isPlaying || ttsState.isLoading)) { ttsController.stop() } } } Popup( alignment = Alignment.BottomCenter, onDismissRequest = onDismiss, properties = PopupProperties(focusable = true) ) { Card( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 5.dp) .heightIn(min = 150.dp, max = 400.dp), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) ) { Column(modifier = Modifier.padding(all = 20.dp)) { if (isLoading && (result?.definition.isNullOrBlank() && result?.error.isNullOrBlank())) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 24.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { CircularProgressIndicator() Text(stringResource(R.string.ai_thinking), modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge) } } else if (result != null) { word?.let { Text( text = it, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = 8.dp) ) } val definitionText = result.definition val errorText = result.error val styledContent = remember(definitionText, errorText) { if (!definitionText.isNullOrBlank()) { MarkdownParser.parse(definitionText) } else { AnnotatedString(errorText ?: "") } } val textToUse = styledContent.text if (textToUse.isNotBlank()) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { definitionText ?: errorText ?: "" val isTtsSessionActive = ttsState.currentText != null || ttsState.isLoading IconButton( onClick = { if (isTtsSessionActive) { ttsController.stop() } else { val chunks = splitTextIntoChunks(textToUse).map { TtsChunk(it, "", -1) } if (chunks.isNotEmpty()) { ttsController.start( chunks = chunks, bookTitle = "AI Definition", chapterTitle = word, coverImageUri = null, ttsMode = loadTtsMode(context), playbackSource = "POPUP" ) } } }, enabled = !isMainTtsActive || (ttsState.playbackSource == "POPUP") ) { Icon( imageVector = if (isTtsSessionActive) Icons.Default.Stop else Icons.Default.PlayArrow, contentDescription = stringResource(if (isTtsSessionActive) R.string.action_stop else R.string.action_read_aloud) ) } Spacer(modifier = Modifier.width(8.dp)) IconButton(onClick = { clipboardManager.setText(AnnotatedString(textToUse)) }) { Icon( imageVector = Icons.Default.ContentCopy, contentDescription = stringResource(R.string.action_copy) ) } Spacer(modifier = Modifier.width(8.dp)) IconButton(onClick = onOpenExternalDictionary) { Icon( painter = painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_open_dictionary) ) } } } HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) if (errorText != null && definitionText.isNullOrBlank()) { Text(errorText, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyLarge) } else if (textToUse.isNotBlank()) { val scrollState = rememberScrollState() var textLayoutResult by remember { mutableStateOf(null) } LaunchedEffect(ttsState.currentText, textLayoutResult) { val currentChunk = ttsState.currentText val layoutResult = textLayoutResult if (!currentChunk.isNullOrBlank() && layoutResult != null) { val startIndex = textToUse.indexOf(currentChunk) if (startIndex != -1) { val line = layoutResult.getLineForOffset(startIndex) val lineTop = layoutResult.getLineTop(line) val viewportHeight = scrollState.viewportSize val targetScroll = (lineTop - viewportHeight / 2).coerceAtLeast(0f) scope.launch { scrollState.animateScrollTo(targetScroll.toInt()) } } } } val annotatedText = buildAnnotatedString { append(styledContent) val currentChunk = ttsState.currentText if (!currentChunk.isNullOrBlank()) { val startIndex = textToUse.indexOf(currentChunk) if (startIndex != -1) { addStyle( style = SpanStyle(background = MaterialTheme.colorScheme.primaryContainer), start = startIndex, end = startIndex + currentChunk.length ) } } } Text( text = annotatedText, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.verticalScroll(scrollState), onTextLayout = { textLayoutResult = it } ) } else { Text(stringResource(R.string.ai_no_definition), style = MaterialTheme.typography.bodyLarge) } } else if (word != null) { Text( text = stringResource(R.string.ai_asking_about, word), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(vertical = 24.dp), maxLines = 1, overflow = TextOverflow.Ellipsis ) } } } } } @Composable fun SearchResultsPanel( results: List, isSearching: Boolean, onResultClick: (SearchResult) -> Unit, modifier: Modifier = Modifier ) { Surface( modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { when { isSearching -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } results.isEmpty() -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(stringResource(R.string.search_no_results_simple), style = MaterialTheme.typography.bodyLarge) } } else -> { Column { Text( text = LocalContext.current.resources.getQuantityString(R.plurals.search_results_count, results.size, results.size), style = MaterialTheme.typography.titleSmall, modifier = Modifier .padding(horizontal = 16.dp, vertical = 12.dp) ) HorizontalDivider() LazyColumn(modifier = Modifier.testTag("SearchResultsList")) { items(results.size) { index -> val result = results[index] ListItem( headlineContent = { Text(result.locationTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) }, supportingContent = { Text(result.snippet, style = MaterialTheme.typography.bodyMedium) }, modifier = Modifier .clickable { onResultClick(result) } .testTag("SearchResultItem_${result.locationInSource}") ) HorizontalDivider() } } } } } } } suspend fun fetchAiDefinition( text: String, context: Context, onUpdate: (String) -> Unit, onError: (String) -> Unit, onFinish: () -> Unit ) { if (text.isBlank()) { onError(context.getString(R.string.error_text_empty)) onFinish() return } Timber.d("Fetching AI definition for: '$text'") withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null try { val url = URL(aiDefinitionUrl) connection = url.openConnection() as HttpURLConnection connection.requestMethod = "POST" connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8") connection.setRequestProperty("Accept", "application/json") connection.connectTimeout = 10000 connection.readTimeout = 30000 connection.doOutput = true connection.doInput = true val jsonPayload = JSONObject().apply { put("text", text) } connection.outputStream.use { os -> os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8)) } val responseCode = connection.responseCode Timber.d("Definition: Got response code $responseCode") if (responseCode == HttpURLConnection.HTTP_OK) { var hasReceivedData = false connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> var line: String? while (reader.readLine().also { line = it } != null) { Timber.d("Definition: Received line: $line") try { val jsonResponse = JSONObject(line!!) jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let { Timber.d("Definition: Parsed chunk, calling onUpdate.") onUpdate(it) hasReceivedData = true } jsonResponse.optString("error").takeIf { it.isNotEmpty() }?.let { onError(it) } } catch (e: Exception) { Timber.w(e, "Could not parse stream line: $line") } } } Timber.d("Definition: Finished reading stream.") if (!hasReceivedData) { onError(context.getString(R.string.error_ai_empty_definition)) } } else { val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null } val errorDetail = try { errorBody?.let { JSONObject(it).getString("detail") } } catch (_: Exception) { "Could not get definition." } onError("${responseCode}. ${errorDetail ?: context.getString(R.string.error_unknown_server)}") } } catch (e: Exception) { Timber.e(e, "Network error fetching AI definition: ${e.message}") onError(context.getString(R.string.error_network_check_connection)) } finally { connection?.disconnect() onFinish() } } } fun countWords(text: String): Int { return text.trim().split(Regex("\\s+")).filter { it.isNotBlank() }.size } object MarkdownParser { fun parse(markdown: String): AnnotatedString { val parser = Parser.builder().build() val document = parser.parse(markdown) val builder = AnnotatedString.Builder() val visitor = object : AbstractVisitor() { override fun visit(text: Text) { builder.append(text.literal) } override fun visit(emphasis: Emphasis) { builder.pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) visitChildren(emphasis) builder.pop() } override fun visit(strongEmphasis: StrongEmphasis) { builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) visitChildren(strongEmphasis) builder.pop() } override fun visit(paragraph: Paragraph) { visitChildren(paragraph) // Add newline if it's not the last node if (paragraph.next != null) { builder.append("\n\n") } } override fun visit(heading: Heading) { builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) visitChildren(heading) builder.pop() builder.append("\n\n") } override fun visit(softLineBreak: SoftLineBreak) { builder.append(" ") } override fun visit(hardLineBreak: HardLineBreak) { builder.append("\n") } override fun visit(code: Code) { builder.pushStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = Color(0x22888888))) builder.append(code.literal) builder.pop() } override fun visit(listItem: ListItem) { builder.append("• ") visitChildren(listItem) if (listItem.next != null) { builder.append("\n") } } } document.accept(visitor) return builder.toAnnotatedString() } } class SummaryCacheManager(context: Context) { private val cacheDir = File(context.cacheDir, "chapter_summaries") init { if (!cacheDir.exists()) { cacheDir.mkdirs() } } private fun getFileName(bookTitle: String, chapterIndex: Int): String { // Sanitize title to be file-system safe val safeTitle = bookTitle.replace(Regex("[^a-zA-Z0-9.-]"), "_") return "summary_${safeTitle}_$chapterIndex.txt" } fun saveSummary(bookTitle: String, chapterIndex: Int, summary: String) { try { val file = File(cacheDir, getFileName(bookTitle, chapterIndex)) file.writeText(summary) Timber.d("Saved summary for $bookTitle Ch $chapterIndex") } catch (e: Exception) { Timber.e(e, "Failed to save summary") } } fun getSummary(bookTitle: String, chapterIndex: Int): String? { return try { val file = File(cacheDir, getFileName(bookTitle, chapterIndex)) if (file.exists()) file.readText() else null } catch (_: Exception) { null } } fun hasSummary(bookTitle: String, chapterIndex: Int): Boolean { val file = File(cacheDir, getFileName(bookTitle, chapterIndex)) return file.exists() } } suspend fun fetchRecap( pastSummaries: List, currentText: String, context: Context, onUpdate: (String) -> Unit, onError: (String) -> Unit, onFinish: () -> Unit ) { if (pastSummaries.isEmpty() && currentText.isBlank()) { onError(context.getString(R.string.error_not_enough_context)) onFinish() return } withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null try { val url = URL(recapUrl) connection = url.openConnection() as HttpURLConnection connection.requestMethod = "POST" connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8") connection.setRequestProperty("Accept", "application/json") connection.connectTimeout = 15000 connection.readTimeout = 120000 connection.doOutput = true connection.doInput = true val jsonPayload = JSONObject().apply { put("past_summaries", org.json.JSONArray(pastSummaries)) put("current_text", currentText) } connection.outputStream.use { os -> os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8)) } val responseCode = connection.responseCode if (responseCode == HttpURLConnection.HTTP_OK) { var hasReceivedData = false connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> var line: String? while (reader.readLine().also { line = it } != null) { try { val jsonResponse = JSONObject(line!!) jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let { onUpdate(it) hasReceivedData = true } jsonResponse.optString("error").takeIf { it.isNotEmpty() }?.let { onError(it) } } catch (e: Exception) { Timber.w(e, "Could not parse stream line: $line") } } } if (!hasReceivedData) onError(context.getString(R.string.error_parse_recap)) } else { val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null } onError("${responseCode}. ${errorBody ?: ""}") } } catch (e: Exception) { Timber.e(e, "Recap error: ${e.message}") onError(context.getString(R.string.error_network_recap)) } finally { connection?.disconnect() onFinish() } } } @androidx.annotation.OptIn(UnstableApi::class) @Composable fun TtsSettingsSheet( isVisible: Boolean, onDismiss: () -> Unit, currentMode: TtsPlaybackManager.TtsMode, onModeChange: (TtsPlaybackManager.TtsMode) -> Unit, currentSpeakerId: String, onSpeakerChange: (String) -> Unit, isTtsActive: Boolean ) { if (isVisible) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) rememberLazyListState() val context = LocalContext.current val scope = rememberCoroutineScope() val samplePlayer = remember(context, scope) { SpeakerSamplePlayer(context, scope) } DisposableEffect(Unit) { onDispose { samplePlayer.release() } } ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, contentWindowInsets = { WindowInsets.navigationBars } ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) .padding(bottom = 24.dp) ) { Text( text = stringResource(R.string.tts_settings), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 16.dp) ) if (isTtsActive) { Surface( color = MaterialTheme.colorScheme.errorContainer, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp) ) { Icon(Icons.Default.Stop, contentDescription = null, tint = MaterialTheme.colorScheme.onErrorContainer) Spacer(Modifier.width(12.dp)) Text( stringResource(R.string.tts_stop_to_change_settings), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer ) } } } Text( text = stringResource(R.string.tts_synthesis_mode), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(bottom = 8.dp) ) // Mode Selector Row( modifier = Modifier .fillMaxWidth() .height(50.dp) .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(25.dp)) .padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { TtsPlaybackManager.TtsMode.entries.forEach { mode -> val isSelected = currentMode == mode val label = if (mode == TtsPlaybackManager.TtsMode.BASE) stringResource(R.string.tts_mode_on_device) else stringResource(R.string.tts_mode_cloud_hq) val icon = if (mode == TtsPlaybackManager.TtsMode.BASE) Icons.Default.Smartphone else Icons.Default.Cloud Surface( modifier = Modifier .weight(1f) .fillMaxHeight() .clickable(enabled = !isTtsActive) { onModeChange(mode) }, shape = RoundedCornerShape(25.dp), color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant ) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) Text( text = label, style = MaterialTheme.typography.labelLarge, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium ) } } } } Spacer(Modifier.height(24.dp)) if (currentMode == TtsPlaybackManager.TtsMode.CLOUD) { Text( text = stringResource(R.string.tts_voice_selection), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(bottom = 8.dp) ) LazyColumn( modifier = Modifier .fillMaxWidth() .heightIn(max = 300.dp) .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)) ) { items(GOOGLE_TTS_SPEAKERS) { (name, id) -> val isSelected = currentSpeakerId == id val isPlaying = samplePlayer.playingSpeakerId == id val isLoading = samplePlayer.loadingSpeakerId == id ListItem( headlineContent = { Text(name, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) }, leadingContent = { if (isSelected) { Icon(Icons.Default.Check, contentDescription = "Selected", tint = MaterialTheme.colorScheme.primary) } else { Icon(Icons.Default.GraphicEq, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) } }, trailingContent = { if (!isTtsActive) { IconButton(onClick = { samplePlayer.playOrStop(id) }) { if (isLoading) { CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) } else { Icon( imageVector = if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow, contentDescription = stringResource(R.string.tts_play_sample), tint = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) } } } }, colors = ListItemDefaults.colors( containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) else Color.Transparent ), modifier = Modifier.clickable(enabled = !isTtsActive) { onSpeakerChange(id) } ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) } } } else { Box( modifier = Modifier .fillMaxWidth() .padding(top = 16.dp), contentAlignment = Alignment.Center ) { Text( "Using system default engine settings.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } } } fun loadNativeVoice(context: Context): String? { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getString(PREF_NATIVE_TTS_VOICE, null) } private fun saveNativeVoice(context: Context, @Suppress("SameParameterValue") voiceName: String?) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) if (voiceName == null) { prefs.edit { remove(PREF_NATIVE_TTS_VOICE) } } else { prefs.edit { putString(PREF_NATIVE_TTS_VOICE, voiceName) } } } @Composable fun DeviceVoiceSettingsSheet( isVisible: Boolean, onDismiss: () -> Unit ) { if (isVisible) { val listState = rememberLazyListState() val context = LocalContext.current @Suppress("UnusedVariable", "Unused") val scope = rememberCoroutineScope() var ttsEngine by remember { mutableStateOf(null) } var allVoices by remember { mutableStateOf>(emptyList()) } var isTtsLoading by remember { mutableStateOf(true) } var savedVoiceName by remember { mutableStateOf(loadNativeVoice(context)) } val allLanguagesOption = "All Languages" var selectedLanguage by remember { mutableStateOf(allLanguagesOption) } val numberedVoiceNames = remember(allVoices) { val nameMap = mutableMapOf() val groupedByLanguage = allVoices.groupBy { it.locale.displayName } groupedByLanguage.forEach { (langName, voiceList) -> if (voiceList.size > 1) { voiceList.forEachIndexed { index, voice -> val type = try { if (voice.isNetworkConnectionRequired) "Online" else "Offline" } catch (_: Exception) { "Offline" } nameMap[voice.name] = "$langName ($type) - ${index + 1}" } } else { val voice = voiceList[0] val type = try { if (voice.isNetworkConnectionRequired) "Online" else "Offline" } catch (_: Exception) { "Offline" } nameMap[voice.name] = "$langName ($type)" } } nameMap } DisposableEffect(Unit) { var tts: TextToSpeech? = null tts = TextToSpeech(context) { status -> if (status == TextToSpeech.SUCCESS) { try { val enginesVoices = tts?.voices if (enginesVoices != null) { allVoices = enginesVoices.toList().sortedBy { it.locale.displayName } } } catch (e: Exception) { Timber.e(e, "Failed to fetch voices") } finally { isTtsLoading = false } } else { isTtsLoading = false Timber.e("TTS Initialization failed with status $status") } } ttsEngine = tts onDispose { tts.shutdown() } } val availableLanguages = remember(allVoices) { val languages = allVoices.asSequence().map { it.locale.displayLanguage }.filter { it.isNotBlank() } .distinct().sorted().toMutableList() languages.add(0, allLanguagesOption) languages } LaunchedEffect(allVoices, savedVoiceName) { if (savedVoiceName != null && allVoices.isNotEmpty()) { val savedVoice = allVoices.find { it.name == savedVoiceName } if (savedVoice != null) { val voiceLanguage = savedVoice.locale.displayLanguage if (selectedLanguage == allLanguagesOption) { selectedLanguage = voiceLanguage } } } } val filteredVoices = remember(selectedLanguage, allVoices) { if (selectedLanguage == allLanguagesOption) { allVoices } else { allVoices.filter { it.locale.displayLanguage == selectedLanguage } } } LaunchedEffect(filteredVoices, savedVoiceName) { if (savedVoiceName != null && filteredVoices.isNotEmpty()) { val index = filteredVoices.indexOfFirst { it.name == savedVoiceName } if (index != -1) { Timber.d("Auto-scrolling to voice at index: $index") delay(300) listState.animateScrollToItem(index) } } } androidx.compose.ui.window.Dialog( onDismissRequest = onDismiss, properties = androidx.compose.ui.window.DialogProperties( usePlatformDefaultWidth = false ) ) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom ) { Box( modifier = Modifier.weight(1f).fillMaxWidth().clickable( interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, indication = null, onClick = onDismiss ) ) Surface( modifier = Modifier.fillMaxWidth().fillMaxHeight(0.9f), shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), color = MaterialTheme.colorScheme.surface, tonalElevation = 6.dp ) { Column( modifier = Modifier.fillMaxSize() .windowInsetsPadding(WindowInsets.navigationBars) .padding(horizontal = 24.dp).padding(bottom = 24.dp, top = 24.dp) ) { Row( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource(R.string.tts_device_voice_settings), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f) ) IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.content_desc_close_settings)) } } Surface( color = if (savedVoiceName == null) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, shape = RoundedCornerShape(16.dp), tonalElevation = if (savedVoiceName == null) 4.dp else 0.dp, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp).clickable { savedVoiceName = null saveNativeVoice(context, null) Timber.d("Native TTS: Reset to System Default") }) { Row( modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.Default.Smartphone, contentDescription = null, tint = if (savedVoiceName == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.tts_system_default), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) Text( text = stringResource(R.string.tts_system_default_desc), style = MaterialTheme.typography.bodySmall ) } if (savedVoiceName == null) { Icon( Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected), tint = MaterialTheme.colorScheme.primary ) } } } HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp)) if (isTtsLoading) { Box( modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center ) { CircularProgressIndicator() Spacer(modifier = Modifier.height(8.dp)) Text(stringResource(R.string.tts_loading_voices), modifier = Modifier.padding(top = 48.dp)) } } else if (allVoices.isEmpty()) { Box( modifier = Modifier.fillMaxWidth().height(100.dp), contentAlignment = Alignment.Center ) { Text( stringResource(R.string.tts_no_voices), color = MaterialTheme.colorScheme.error ) } } else { var expandedLanguageMenu by remember { mutableStateOf(false) } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource(R.string.tts_specific_voices), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) } Spacer(modifier = Modifier.height(8.dp)) Box(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { Surface( modifier = Modifier.fillMaxWidth().height(50.dp) .clickable { expandedLanguageMenu = true }, shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surface, border = BorderStroke( 1.dp, MaterialTheme.colorScheme.outlineVariant ) ) { Row( modifier = Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = selectedLanguage, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis ) Icon( imageVector = Icons.Default.ArrowDropDown, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } DropdownMenu( expanded = expandedLanguageMenu, onDismissRequest = { expandedLanguageMenu = false }, modifier = Modifier.fillMaxWidth(0.85f).heightIn(max = 400.dp) .background(MaterialTheme.colorScheme.surfaceContainerHigh) ) { availableLanguages.forEach { language -> DropdownMenuItem(text = { Text( text = language, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface ) }, onClick = { selectedLanguage = language expandedLanguageMenu = false }) } } } if (filteredVoices.isNotEmpty()) { Text( text = stringResource(R.string.tts_available_voices_count, filteredVoices.size), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(bottom = 8.dp, start = 4.dp) ) LazyColumn( state = listState, modifier = Modifier.fillMaxWidth().weight(1f, fill = false) .padding(vertical = 4.dp).border( 1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp) ) ) { items( filteredVoices.size, key = { "${filteredVoices[it].name}_$it" }) { index -> val voice = filteredVoices[index] val isSelected = voice.name == savedVoiceName val friendlyName = numberedVoiceNames[voice.name] ?: voice.locale.displayName ListItem( headlineContent = { Text( text = friendlyName, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, color = MaterialTheme.colorScheme.onSurface ) }, supportingContent = if (voice.locale.variant.isNotEmpty()) { { Text(stringResource(R.string.tts_voice_variant, voice.locale.variant)) } } else null, leadingContent = { if (isSelected) { Icon( Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected), tint = MaterialTheme.colorScheme.primary ) } else { Spacer(modifier = Modifier.size(24.dp)) } }, trailingContent = { IconButton(onClick = { val params = android.os.Bundle() try { ttsEngine?.language = voice.locale } catch (e: Exception) { Timber.e(e, "Failed to set language for sample") } ttsEngine?.voice = voice val sampleText = context.getString(R.string.tts_voice_sample_text, voice.locale.displayLanguage) ttsEngine?.speak( sampleText, TextToSpeech.QUEUE_FLUSH, params, "SAMPLE_ID" ) }) { Icon( imageVector = Icons.Default.PlayArrow, contentDescription = stringResource(R.string.tts_play_sample), tint = MaterialTheme.colorScheme.primary ) } }, modifier = Modifier.clickable { savedVoiceName = voice.name saveNativeVoice(context, voice.name) }.background( if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy( alpha = 0.2f ) else Color.Transparent ) ) HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant.copy( alpha = 0.5f ) ) } } } else { Box( modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center ) { Text( stringResource(R.string.tts_no_voices_for_language), color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } } } } } } @Composable fun SpectrumBox( hue: Float, saturation: Float, currentColor: Color, onHueSatChanged: (Float, Float) -> Unit, modifier: Modifier = Modifier ) { val rainbowColors = listOf( Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red ) val touchPadding = 12.dp Box( modifier = modifier.pointerInput(Unit) { awaitEachGesture { val down = awaitFirstDown() val paddingPx = touchPadding.toPx() val activeWidth = size.width.toFloat() - (paddingPx * 2) val activeHeight = size.height.toFloat() - (paddingPx * 2) fun update(offset: Offset) { val relativeX = offset.x - paddingPx val relativeY = offset.y - paddingPx val h = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f val s = (relativeY / activeHeight).coerceIn(0f, 1f) onHueSatChanged(h, s) } update(down.position) drag(down.id) { change -> change.consume() update(change.position) } } } ) { Canvas( modifier = Modifier .fillMaxSize() .padding(touchPadding) .clip(RoundedCornerShape(12.dp)) ) { drawRect( brush = Brush.horizontalGradient(rainbowColors) ) drawRect( brush = Brush.verticalGradient( colors = listOf(Color.White, Color.White.copy(alpha = 0f)) ) ) } Canvas(modifier = Modifier.fillMaxSize()) { val paddingPx = touchPadding.toPx() val activeWidth = size.width - (paddingPx * 2) val activeHeight = size.height - (paddingPx * 2) val x = paddingPx + (hue / 360f) * activeWidth val y = paddingPx + saturation * activeHeight val pointerRadius = 10.dp.toPx() val strokeWidth = 2.dp.toPx() drawCircle( color = Color.Black.copy(alpha = 0.25f), radius = pointerRadius + 1.dp.toPx(), center = Offset(x, y + 1.dp.toPx()) ) drawCircle( color = currentColor.copy(alpha = 1f), radius = pointerRadius, center = Offset(x, y) ) drawCircle( color = Color.White, radius = pointerRadius, center = Offset(x, y), style = Stroke(width = strokeWidth) ) } } } @Composable fun BrightnessSlider( hue: Float, saturation: Float, value: Float, onValueChanged: (Float) -> Unit, modifier: Modifier = Modifier ) { val baseColor = remember(hue, saturation) { Color.hsv(hue, saturation, 1f) } Box( modifier = modifier.pointerInput(Unit) { awaitEachGesture { val down = awaitFirstDown() fun update(offset: Offset) { val v = (offset.x / size.width.toFloat()).coerceIn(0f, 1f) onValueChanged(v) } update(down.position) drag(down.id) { change -> change.consume() update(change.position) } } } ) { Canvas(modifier = Modifier.fillMaxSize()) { drawRect( brush = Brush.horizontalGradient( colors = listOf(Color.Black, baseColor) ) ) val x = value * size.width drawCircle( color = Color.White, radius = 8.dp.toPx(), center = Offset(x, size.height / 2) ) } } } @Composable fun RgbInputColumn( label: String, value: Float, onValueChange: (Float) -> Unit, modifier: Modifier = Modifier ) { val intValue = (value * 255).roundToInt() Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier ) { Text( text = label, color = Color.Gray, fontSize = 11.sp, maxLines = 1 ) Spacer(Modifier.height(4.dp)) RgbInput(value = intValue, onValueChange = onValueChange) } } @Composable fun RgbInput( value: Int, onValueChange: (Float) -> Unit ) { var text by remember(value) { mutableStateOf(value.toString()) } LaunchedEffect(value) { text = value.toString() } BasicTextField( value = text, onValueChange = { newText -> if (newText.length <= 3 && newText.all { it.isDigit() }) { val intVal = newText.toIntOrNull() if (intVal != null) { onValueChange(intVal.coerceIn(0, 255) / 255f) } } }, textStyle = TextStyle( color = Color.White, textAlign = TextAlign.Center, fontSize = 13.sp ), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, modifier = Modifier .fillMaxWidth() .height(36.dp) .background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp)) .padding(vertical = 9.dp) ) } @Composable fun HexInput( color: Color, onHexChanged: (Color) -> Unit ) { val hexValue = remember(color) { String.format("%06X", (0xFFFFFF and color.toArgb())) } var text by remember(hexValue) { mutableStateOf(hexValue) } LaunchedEffect(color) { val currentParsed = try { Color(("#$text").toColorInt()) } catch (_: Exception) { null } if (currentParsed?.toArgb() != color.toArgb()) { text = String.format("%06X", (0xFFFFFF and color.toArgb())) } } Row( modifier = Modifier .fillMaxWidth() .height(36.dp) .background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp)) .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { Text( text = "#", color = Color.Gray, fontSize = 13.sp, fontWeight = FontWeight.Bold ) BasicTextField( value = text, onValueChange = { newText -> if (newText.length <= 6) { val uppercased = newText.uppercase() if (uppercased.all { it.isDigit() || it in 'A'..'F' }) { text = uppercased if (uppercased.length == 6) { try { val parsedColorInt = "#$uppercased".toColorInt() val newColor = Color(parsedColorInt) onHexChanged(newColor) } catch (_: Exception) { } } } } }, textStyle = TextStyle( color = Color.White, textAlign = TextAlign.Start, fontSize = 13.sp ), singleLine = true, cursorBrush = SolidColor(Color.White), modifier = Modifier .padding(start = 2.dp) .width(50.dp) ) } } @Composable fun ColorComparePill( oldColor: Color, newColor: Color, modifier: Modifier = Modifier ) { Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) { drawRect( color = oldColor.copy(alpha = 1f), size = androidx.compose.ui.geometry.Size(size.width / 2, size.height) ) drawRect( color = newColor.copy(alpha = 1f), topLeft = Offset(size.width / 2, 0f), size = androidx.compose.ui.geometry.Size(size.width / 2, size.height) ) } } enum class ReaderTexture(val id: String, val resId: Int, val displayName: String) { PAPER("paper", R.drawable.texture_paper, "Paper"), CANVAS("canvas", R.drawable.texture_canvas, "Canvas"), EINK("eink", R.drawable.texture_eink, "E-Ink"), SLATE("slate", R.drawable.texture_slate, "Slate") } data class ReaderTheme( val id: String, val name: String, val backgroundColor: Color, val textColor: Color, val isDark: Boolean, val textureId: String? = null, val isCustom: Boolean = false ) val BuiltInThemes = listOf( ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false), ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false), ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true), ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false), ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true), ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true) ) fun saveReaderThemeId(context: Context, themeId: String) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putString(PREF_READER_THEME, themeId) } } fun loadReaderThemeId(context: Context): String { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) return prefs.getString(PREF_READER_THEME, "system") ?: "system" } fun saveCustomThemes(context: Context, themes: List) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val jsonArray = JSONArray() themes.filter { it.isCustom }.forEach { theme -> val obj = JSONObject().apply { put("id", theme.id) put("name", theme.name) put("bgColor", theme.backgroundColor.toArgb()) put("textColor", theme.textColor.toArgb()) put("isDark", theme.isDark) theme.textureId?.let { put("textureId", it) } } jsonArray.put(obj) } prefs.edit { putString(PREF_CUSTOM_THEMES, jsonArray.toString()) } } fun loadCustomThemes(context: Context): List { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val jsonString = prefs.getString(PREF_CUSTOM_THEMES, "[]") ?: "[]" val themes = mutableListOf() try { val jsonArray = org.json.JSONArray(jsonString) for (i in 0 until jsonArray.length()) { val obj = jsonArray.getJSONObject(i) themes.add( ReaderTheme( id = obj.getString("id"), name = obj.getString("name"), backgroundColor = Color(obj.getInt("bgColor")), textColor = Color(obj.getInt("textColor")), isDark = obj.getBoolean("isDark"), textureId = if (obj.has("textureId")) obj.getString("textureId") else null, isCustom = true ) ) } } catch (e: Exception) { Timber.e(e, "Failed to parse custom themes") } return themes } private fun calculateContrastRatio(color1: Color, color2: Color): Float { val l1 = max(color1.luminance(), color2.luminance()) val l2 = min(color1.luminance(), color2.luminance()) return (l1 + 0.05f) / (l2 + 0.05f) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun ReaderThemePanel( isVisible: Boolean, currentThemeId: String, customThemes: List, builtInThemes: List = BuiltInThemes, onThemeSelected: (String) -> Unit, onCustomThemesUpdated: (List) -> Unit, onDismiss: () -> Unit ) { if (!isVisible) return var showBuilder by remember { mutableStateOf(false) } var editingTheme by remember { mutableStateOf(null) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, contentWindowInsets = { WindowInsets.navigationBars } ) { AnimatedContent(targetState = showBuilder, label = "ThemePanelTransition") { isBuilding -> if (isBuilding) { ThemeBuilderView( initialTheme = editingTheme, onSave = { newTheme -> val updatedList = if (editingTheme != null) { customThemes.map { if (it.id == newTheme.id) newTheme else it } } else { customThemes + newTheme } onCustomThemesUpdated(updatedList) onThemeSelected(newTheme.id) showBuilder = false editingTheme = null }, onCancel = { showBuilder = false editingTheme = null } ) } else { Column( modifier = Modifier .fillMaxWidth() .fillMaxHeight(0.65f) .padding(16.dp) .padding(bottom = 16.dp) ) { Text(stringResource(R.string.reading_themes), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 16.dp) ) Text(stringResource(R.string.theme_presets), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) Spacer(Modifier.height(8.dp)) ThemeGrid(themes = builtInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected) Spacer(Modifier.height(24.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text(stringResource(R.string.theme_my_themes), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) IconButton(onClick = { editingTheme = null; showBuilder = true }, modifier = Modifier.size(24.dp)) { Icon(Icons.Default.Add, contentDescription = "Create Theme", tint = MaterialTheme.colorScheme.primary) } } Spacer(Modifier.height(8.dp)) if (customThemes.isEmpty()) { Text(stringResource(R.string.theme_no_custom), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } else { ThemeGrid( themes = customThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected, onEdit = { editingTheme = it; showBuilder = true }, onDelete = { themeToDelete -> val updated = customThemes.filter { it.id != themeToDelete.id } onCustomThemesUpdated(updated) if (currentThemeId == themeToDelete.id) onThemeSelected("system") } ) } } } } } } @Composable fun ThemeGrid( themes: List, currentThemeId: String, onThemeSelected: (String) -> Unit, onEdit: ((ReaderTheme) -> Unit)? = null, onDelete: ((ReaderTheme) -> Unit)? = null ) { androidx.compose.foundation.lazy.grid.LazyVerticalGrid( columns = androidx.compose.foundation.lazy.grid.GridCells.Adaptive(minSize = 80.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { items(themes.size) { index -> val theme = themes[index] val isSelected = currentThemeId == theme.id val bgColor = if (theme.id == "system") MaterialTheme.colorScheme.surfaceVariant else theme.backgroundColor val textColor = if (theme.id == "system") MaterialTheme.colorScheme.onSurfaceVariant else theme.textColor val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant Column( horizontalAlignment = Alignment.CenterHorizontally, ) { Box( modifier = Modifier .size(56.dp) .background(bgColor, CircleShape) .border(if (isSelected) 3.dp else 1.dp, borderColor, CircleShape) .clickable { onThemeSelected(theme.id) }, contentAlignment = Alignment.Center ) { Text(text = "Aa", color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) } Spacer(modifier = Modifier.height(8.dp)) Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis) if (theme.isCustom && onEdit != null && onDelete != null) { Spacer(modifier = Modifier.height(6.dp)) Surface( shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceVariant, ) { Row( modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Icon(Icons.Default.Edit, "Edit", Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary) Spacer(Modifier.width(4.dp)) Icon(Icons.Default.Delete, "Delete", Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error) } } } } } } } @Composable fun ThemeBuilderView( initialTheme: ReaderTheme?, onSave: (ReaderTheme) -> Unit, onCancel: () -> Unit ) { var name by remember { mutableStateOf(initialTheme?.name ?: "Custom Theme") } var bgColor by remember { mutableStateOf(initialTheme?.backgroundColor ?: Color(0xFFF5F5F5)) } var txtColor by remember { mutableStateOf(initialTheme?.textColor ?: Color(0xFF111111)) } var textureId by remember { mutableStateOf(initialTheme?.textureId) } var editingColorType by remember { mutableStateOf(null) } val contrast = calculateContrastRatio(bgColor, txtColor) val isDark = bgColor.luminance() < 0.5f Column( modifier = Modifier .fillMaxWidth() .fillMaxHeight(0.85f) .padding(16.dp) ) { Text( text = stringResource(if (initialTheme == null) R.string.theme_new else R.string.theme_edit), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface ) Spacer(Modifier.height(16.dp)) Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) { androidx.compose.material3.OutlinedTextField( value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.theme_name)) }, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true ) // Live Preview Card Surface( modifier = Modifier.fillMaxWidth().height(120.dp).padding(vertical = 8.dp), shape = RoundedCornerShape(12.dp), color = bgColor, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { val context = LocalContext.current Box(modifier = Modifier.fillMaxSize().run { val texRes = ReaderTexture.entries.find { it.id == textureId }?.resId if (texRes != null) { val bmp = ImageBitmap.imageResource(context.resources, texRes) this.drawBehind { drawRect(ShaderBrush(ImageShader(bmp, TileMode.Repeated, TileMode.Repeated)), blendMode = BlendMode.Multiply, alpha = 0.5f) } } else this }) { Column(Modifier.padding(16.dp).fillMaxWidth()) { Text(text = stringResource(R.string.theme_preview_quote), color = txtColor, style = MaterialTheme.typography.titleMedium ) Spacer(Modifier.height(8.dp)) Text(text = stringResource(R.string.theme_preview_author), color = txtColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.End ) } } } // Animated Contrast Warning AnimatedVisibility(visible = contrast < 4.5f) { Text(stringResource(R.string.theme_low_contrast_warning), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(bottom = 8.dp) ) } Spacer(Modifier.height(16.dp)) // Sleek Color Swatches Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { ColorSwatchItem( label = stringResource(R.string.theme_page_color), color = bgColor, onClick = { editingColorType = "bg" }, modifier = Modifier.weight(1f) ) ColorSwatchItem( label = stringResource(R.string.theme_text_color), color = txtColor, onClick = { editingColorType = "text" }, modifier = Modifier.weight(1f) ) } Spacer(Modifier.height(16.dp)) } // Action Buttons at the bottom for better visibility Row( modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { TextButton(onClick = onCancel) { Text(stringResource(R.string.action_cancel), color = MaterialTheme.colorScheme.primary) } Spacer(Modifier.width(8.dp)) Button(onClick = { onSave(ReaderTheme(id = initialTheme?.id ?: System.currentTimeMillis().toString(), name = name, backgroundColor = bgColor, textColor = txtColor, isDark = isDark, textureId = textureId, isCustom = true)) }) { Text(stringResource(R.string.action_save), color = MaterialTheme.colorScheme.onPrimary) } } } editingColorType?.let { type -> ThemeColorPickerDialog( initialColor = if (type == "bg") bgColor else txtColor, title = if (type == "bg") stringResource(R.string.theme_page_color) else stringResource(R.string.theme_text_color), bgColor = bgColor, textColor = txtColor, editingColorType = type, onDismiss = { editingColorType = null }, onColorChanged = { newColor -> if (type == "bg") bgColor = newColor else txtColor = newColor } ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun ColorSwatchItem(label: String, color: Color, onClick: () -> Unit, modifier: Modifier = Modifier) { Column(modifier = modifier) { Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 8.dp)) Surface( onClick = onClick, shape = RoundedCornerShape(12.dp), color = color, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), modifier = Modifier.fillMaxWidth().height(56.dp) ) {} } } @Composable fun ThemeColorPickerDialog( initialColor: Color, title: String, bgColor: Color, textColor: Color, editingColorType: String, onDismiss: () -> Unit, onColorChanged: (Color) -> Unit ) { val initialHsv = remember(initialColor) { val hsv = FloatArray(3) android.graphics.Color.colorToHSV(initialColor.toArgb(), hsv) hsv } var hue by remember { mutableFloatStateOf(initialHsv[0]) } var saturation by remember { mutableFloatStateOf(initialHsv[1]) } var value by remember { mutableFloatStateOf(initialHsv[2]) } val currentColor by remember { derivedStateOf { val hsv = floatArrayOf(hue, saturation, value) val argb = android.graphics.Color.HSVToColor(255, hsv) Color(argb) } } LaunchedEffect(currentColor) { onColorChanged(currentColor) } fun updateFromColor(color: Color) { val hsv = FloatArray(3) android.graphics.Color.colorToHSV(color.toArgb(), hsv) hue = hsv[0] saturation = hsv[1] value = hsv[2] } androidx.compose.ui.window.Dialog( onDismissRequest = onDismiss, properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false) ) { Surface( shape = RoundedCornerShape(24.dp), color = Color(0xFF2C2C2C), modifier = Modifier .fillMaxWidth(0.9f) .padding(16.dp) ) { Column( modifier = Modifier .padding(20.dp) .verticalScroll(rememberScrollState()), // Prevents elements from hiding off-screen horizontalAlignment = Alignment.CenterHorizontally ) { Box( modifier = Modifier .background(Color(0xFF3E3E3E), RoundedCornerShape(16.dp)) .padding(horizontal = 24.dp, vertical = 8.dp) ) { Text( text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color.White ) } Spacer(Modifier.height(16.dp)) val liveBgColor = if (editingColorType == "bg") currentColor else bgColor val liveTextColor = if (editingColorType == "text") currentColor else textColor Surface( modifier = Modifier.fillMaxWidth().height(64.dp), shape = RoundedCornerShape(12.dp), color = liveBgColor, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { Text(text = stringResource(R.string.theme_color_live_preview), color = liveTextColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold ) Text(text = stringResource(R.string.theme_color_preview_text), color = liveTextColor, style = MaterialTheme.typography.bodySmall ) } } Spacer(Modifier.height(20.dp)) SpectrumBox( hue = hue, saturation = saturation, currentColor = currentColor, onHueSatChanged = { h, s -> hue = h; saturation = s }, modifier = Modifier.fillMaxWidth().height(220.dp) ) Spacer(Modifier.height(20.dp)) BrightnessSlider( hue = hue, saturation = saturation, value = value, onValueChanged = { value = it }, modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp)) ) Spacer(Modifier.height(24.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { ColorComparePill( oldColor = initialColor, newColor = currentColor, modifier = Modifier.width(64.dp).height(36.dp) ) Column( modifier = Modifier.weight(1.6f), horizontalAlignment = Alignment.CenterHorizontally ) { Text(stringResource(R.string.theme_color_hex), color = Color.Gray, fontSize = 12.sp, maxLines = 1) Spacer(Modifier.height(4.dp)) HexInput(color = currentColor, onHexChanged = { updateFromColor(it) }) } Row( modifier = Modifier.weight(2.4f), horizontalArrangement = Arrangement.spacedBy(6.dp) ) { RgbInputColumn(label = stringResource(R.string.color_r), value = currentColor.red, onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) }, modifier = Modifier.weight(1f) ) RgbInputColumn(label = stringResource(R.string.color_g), value = currentColor.green, onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) }, modifier = Modifier.weight(1f) ) RgbInputColumn(label = stringResource(R.string.color_b), value = currentColor.blue, onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) }, modifier = Modifier.weight(1f) ) } } Spacer(Modifier.height(24.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { Button( onClick = onDismiss, colors = androidx.compose.material3.ButtonDefaults.buttonColors( containerColor = Color.White ) ) { Text(stringResource(R.string.action_save), color = Color.Black, fontWeight = FontWeight.Bold) } } } } } } @Composable fun TextureOption(name: String, resId: Int?, isSelected: Boolean, onClick: () -> Unit) { Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.clickable(onClick = onClick)) { Box(modifier = Modifier.size(48.dp).clip(CircleShape).border(if (isSelected) 3.dp else 1.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, CircleShape).run { if (resId != null) { val bmp = ImageBitmap.imageResource(LocalResources.current, resId) this.drawBehind { drawRect(ShaderBrush(ImageShader(bmp, TileMode.Repeated, TileMode.Repeated))) } } else this.background(MaterialTheme.colorScheme.surfaceVariant) }) Text(name, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 4.dp)) } } @Composable fun ColorSlider(color: Color, onColorChanged: (Color) -> Unit) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Slider(value = color.red, onValueChange = { onColorChanged(color.copy(red = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Red, activeTrackColor = Color.Red), modifier = Modifier.weight(1f)) Slider(value = color.green, onValueChange = { onColorChanged(color.copy(green = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Green, activeTrackColor = Color.Green), modifier = Modifier.weight(1f)) Slider(value = color.blue, onValueChange = { onColorChanged(color.copy(blue = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Blue, activeTrackColor = Color.Blue), modifier = Modifier.weight(1f)) } }