package org.terst.feedreader.ui 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button 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.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.DEFAULT_READER_FONT_SIZE_PX import org.terst.feedreader.data.FeedEntity import org.terst.feedreader.data.FilterAction import org.terst.feedreader.data.FilterRuleEntity import org.terst.feedreader.data.Net import org.terst.feedreader.data.READER_FONT_SIZE_RANGE_PX import org.terst.feedreader.data.Settings import org.terst.feedreader.stash.StashClient import org.terst.feedreader.stash.StashVerifyResult import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(onBack: () -> Unit) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } val settings = remember { Settings(context) } val scope = rememberCoroutineScope() var stashUrl by remember { mutableStateOf("") } var stashKey by remember { mutableStateOf("") } var stashKeyVisible by remember { mutableStateOf(false) } var isVerifyingStash by remember { mutableStateOf(false) } var stashStatus by remember { mutableStateOf(null) } var fontSizePx by remember { mutableStateOf(DEFAULT_READER_FONT_SIZE_PX) } LaunchedEffect(Unit) { stashUrl = settings.stashBaseUrlNow() stashKey = settings.stashApiKeyNow() fontSizePx = settings.readerFontSizePxNow() } var newRulePattern by remember { mutableStateOf("") } var newRuleAction by remember { mutableStateOf(FilterAction.MUTE) } var actionMenuExpanded by remember { mutableStateOf(false) } val rules by db.filterRuleDao().observeAll().collectAsState(initial = emptyList()) val feeds by db.feedDao().observeAll().collectAsState(initial = emptyList()) val feedTitleById = remember(feeds) { feeds.associate { it.id to it.title } } // null = applies to every feed (the only option before this scope picker existed). var newRuleFeedId by remember { mutableStateOf(null) } var feedMenuExpanded by remember { mutableStateOf(false) } Scaffold( topBar = { TopAppBar( title = { Text("Settings") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") } } ) } ) { padding -> LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) { item { Column(modifier = Modifier.padding(16.dp)) { Text("FeeDreader", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(4.dp)) Text( "FeeDreader exists to avoid tracking, manipulation, and junk fees. If an " + "article's full text is something you could just as easily find with a " + "search, reader mode will pull it up here too.", style = MaterialTheme.typography.bodyMedium ) } HorizontalDivider() } item { Column(modifier = Modifier.padding(16.dp)) { Text("Reading", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(8.dp)) Text("Font size: ${fontSizePx}px", style = MaterialTheme.typography.bodyMedium) Slider( value = fontSizePx.toFloat(), onValueChange = { fontSizePx = it.roundToInt() }, valueRange = READER_FONT_SIZE_RANGE_PX.first.toFloat()..READER_FONT_SIZE_RANGE_PX.last.toFloat(), steps = READER_FONT_SIZE_RANGE_PX.last - READER_FONT_SIZE_RANGE_PX.first - 1, // Persist on release, not on every onValueChange -- a drag crosses many // integer values in a fraction of a second, and there's no reason to hit // DataStore that often for a value nothing reads until the next article load. onValueChangeFinished = { scope.launch { settings.setReaderFontSizePx(fontSizePx) } } ) } HorizontalDivider() } item { Column(modifier = Modifier.padding(16.dp)) { Text("Stash", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( value = stashUrl, onValueChange = { stashUrl = it }, label = { Text("Base URL") }, placeholder = { Text("https://stash.example.com") }, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( value = stashKey, onValueChange = { stashKey = it; stashStatus = null }, label = { Text("API key") }, singleLine = true, visualTransformation = if (stashKeyVisible) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { IconButton(onClick = { stashKeyVisible = !stashKeyVisible }) { Icon( if (stashKeyVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, contentDescription = if (stashKeyVisible) "Hide API key" else "Show API key" ) } }, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) Button( enabled = !isVerifyingStash, onClick = { scope.launch { isVerifyingStash = true stashStatus = null val client = StashClient(Net.client, stashUrl.trim(), stashKey.trim()) when (val result = client.verifyCredentials()) { is StashVerifyResult.Valid -> { settings.setStash(stashUrl.trim(), stashKey.trim()) stashStatus = "Verified and saved" } is StashVerifyResult.Invalid -> { stashStatus = result.message } } isVerifyingStash = false } }, modifier = Modifier.fillMaxWidth() ) { if (isVerifyingStash) { CircularProgressIndicator(modifier = Modifier.height(20.dp).width(20.dp)) } else { Text("Verify and save") } } stashStatus?.let { Spacer(modifier = Modifier.height(8.dp)) Text(it, style = MaterialTheme.typography.bodySmall) } } HorizontalDivider() } item { Column(modifier = Modifier.padding(16.dp)) { Text("Filter rules", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(8.dp)) Row(modifier = Modifier.fillMaxWidth()) { OutlinedTextField( value = newRulePattern, onValueChange = { newRulePattern = it }, label = { Text("Keyword") }, modifier = Modifier.weight(1f) ) Spacer(modifier = Modifier.width(8.dp)) Box { Button(onClick = { actionMenuExpanded = true }) { Text(if (newRuleAction == FilterAction.MUTE) "Mute" else "Highlight") } DropdownMenu(expanded = actionMenuExpanded, onDismissRequest = { actionMenuExpanded = false }) { DropdownMenuItem(text = { Text("Mute") }, onClick = { newRuleAction = FilterAction.MUTE; actionMenuExpanded = false }) DropdownMenuItem(text = { Text("Highlight") }, onClick = { newRuleAction = FilterAction.HIGHLIGHT; actionMenuExpanded = false }) } } } Spacer(modifier = Modifier.height(8.dp)) FeedScopePicker( feeds = feeds, selectedFeedId = newRuleFeedId, expanded = feedMenuExpanded, onExpandedChange = { feedMenuExpanded = it }, onSelect = { newRuleFeedId = it } ) Spacer(modifier = Modifier.height(8.dp)) Button( enabled = newRulePattern.isNotBlank(), onClick = { scope.launch { db.filterRuleDao().insert( FilterRuleEntity( pattern = newRulePattern.trim(), action = newRuleAction, feedId = newRuleFeedId, createdAt = System.currentTimeMillis() ) ) newRulePattern = "" newRuleFeedId = null } }, modifier = Modifier.fillMaxWidth() ) { Text("Add rule") } } } items(rules, key = { it.id }) { rule -> ListItem( headlineContent = { Text(rule.pattern) }, supportingContent = { val actionLabel = if (rule.action == FilterAction.MUTE) "Mute" else "Highlight" val scopeLabel = rule.feedId?.let { feedTitleById[it] ?: it } ?: "All feeds" Text("$actionLabel ยท $scopeLabel") }, trailingContent = { IconButton(onClick = { scope.launch { db.filterRuleDao().delete(rule.id) } }) { Icon(Icons.Default.Delete, contentDescription = "Delete rule") } } ) HorizontalDivider() } } } } /** Scope picker for a new filter rule: "All feeds" (feedId = null) or one specific feed. */ @Composable private fun FeedScopePicker( feeds: List, selectedFeedId: String?, expanded: Boolean, onExpandedChange: (Boolean) -> Unit, onSelect: (String?) -> Unit ) { Box { Button(onClick = { onExpandedChange(true) }, modifier = Modifier.fillMaxWidth()) { Text(feeds.find { it.id == selectedFeedId }?.title ?: "All feeds") } DropdownMenu(expanded = expanded, onDismissRequest = { onExpandedChange(false) }) { DropdownMenuItem( text = { Text("All feeds") }, onClick = { onSelect(null); onExpandedChange(false) } ) feeds.forEach { feed -> DropdownMenuItem( text = { Text(feed.title) }, onClick = { onSelect(feed.id); onExpandedChange(false) } ) } } } }