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.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.FeedEntity import org.terst.feedreader.data.FilterAction import org.terst.feedreader.data.FilterRuleEntity import org.terst.feedreader.data.Net import org.terst.feedreader.data.Settings import org.terst.feedreader.stash.StashClient import org.terst.feedreader.stash.StashVerifyResult @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) } LaunchedEffect(Unit) { stashUrl = settings.stashBaseUrlNow() stashKey = settings.stashApiKeyNow() } 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( "If a site paywalls its own feed, that's the site's call. But this reader " + "won't go out of its way to help a soft paywall block you from reading " + "something you already subscribed to get -- reader mode extracts the " + "article you were sent, nothing more, nothing sneaky.", style = MaterialTheme.typography.bodyMedium ) } 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) } ) } } } }