feat(settings): modernize Privacy, Profile UI, Home Tabs & Calendar Reminder screens

Bring four settings screens onto the SettingsSection card kit used by the
UI Preferences / Security Filters screens, and drop the last Save/Cancel
flow in favor of auto-save + back-button-only navigation.

- Privacy Options: remove SavingTopBar + TorDialogViewModel staging; bind
  controls directly to TorSettingsFlow.tryEmit (the existing debounced
  propertyWatchFlow already persists changes), so edits save on change and
  the screen has only a back arrow. Rebuild the body from SettingsSection
  cards: a segmented Tor-engine tile, an Orbot-port block, a live preset
  picker row, and per-usage switch tiles.
- Profile UI & Home Tabs: reskin the ad-hoc rows into SettingsSection
  cards with SettingsSwitchTile / SegmentedChoiceTile (Home Tabs keeps the
  "can't disable the last tab" guard via the enabled flag).
- Calendar Reminders: swap the hand-rolled TopAppBar for
  TopBarWithBackButton and reskin into a SettingsSection card with a
  segmented lead-time selector, preserving the CalendarReminderPrefs writes
  and WorkManager schedule/cancel side-effects.
- Shared kit: make SegmentedChoiceTile internal for reuse and add a compact
  title-only SettingsSwitchTile overload with enabled support.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JvrwLYbGVJQhabfZS7sBy
This commit is contained in:
Claude
2026-07-23 00:57:09 +00:00
parent 7a9457e88b
commit 7c4090a800
7 changed files with 466 additions and 328 deletions
@@ -20,37 +20,38 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSwitchTile
import com.vitorpamplona.amethyst.ui.stringRes
@OptIn(ExperimentalMaterial3Api::class)
@@ -63,44 +64,25 @@ fun CalendarReminderSettingsScreen(nav: INav) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.calendar_reminder_settings_title)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
},
)
TopBarWithBackButton(stringRes(R.string.calendar_reminder_settings_title), nav)
},
) { pad ->
) { padding ->
Column(
modifier =
Modifier
.padding(pad)
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.calendar_reminder_settings_enabled_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.calendar_reminder_settings_enabled_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
SettingsSection(R.string.settings_section_reminders) {
// The switch writes straight to the device-scoped prefs and re-schedules (or
// cancels) the WorkManager job on the spot — no Save button, the change is applied
// the instant it's toggled.
SettingsSwitchTile(
icon = MaterialSymbols.Notifications,
title = R.string.calendar_reminder_settings_enabled_title,
description = R.string.calendar_reminder_settings_enabled_subtitle,
checked = enabled,
onCheckedChange = {
enabled = it
@@ -115,35 +97,29 @@ fun CalendarReminderSettingsScreen(nav: INav) {
}
},
)
}
Text(
text = stringRes(R.string.calendar_reminder_settings_lead_title),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 8.dp),
)
Text(
text = stringRes(R.string.calendar_reminder_settings_lead_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CalendarReminderPrefs.LEAD_TIME_CHOICES.forEach { choice ->
FilterChip(
selected = choice == leadMinutes,
onClick = {
leadMinutes = choice
prefs.setLeadMinutes(choice)
},
enabled = enabled,
label = {
Text(pluralStringResource(R.plurals.calendar_reminder_settings_lead_choice, choice, choice))
},
)
SettingsDivider()
SettingsBlockTile(
icon = MaterialSymbols.Schedule,
title = stringRes(R.string.calendar_reminder_settings_lead_title),
description = stringRes(R.string.calendar_reminder_settings_lead_subtitle),
) {
val choices = CalendarReminderPrefs.LEAD_TIME_CHOICES
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
choices.forEachIndexed { index, choice ->
SegmentedButton(
selected = choice == leadMinutes,
enabled = enabled,
onClick = {
leadMinutes = choice
prefs.setLeadMinutes(choice)
},
shape = SegmentedButtonDefaults.itemShape(index = index, count = choices.size),
icon = {},
) {
Text(pluralStringResource(R.plurals.calendar_reminder_settings_lead_choice, choice, choice))
}
}
}
}
}
}
@@ -20,26 +20,71 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.tor.TorPresetType
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset
import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate
import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset
import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset
import com.vitorpamplona.amethyst.commons.tor.whichPreset
import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.tor.PrivacySettingsBody
import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SegmentedChoiceTile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsControlRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSwitchTile
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.explainerId
import com.vitorpamplona.amethyst.ui.tor.resourceId
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -50,54 +95,230 @@ fun PrivacyOptionsScreen(nav: INav) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivacyOptionsScreen(
torSettingsFlow: TorSettingsFlow,
nav: INav,
) {
val dialogViewModel = viewModel<TorDialogViewModel>()
// runs only once and before the rest of the screen is build
// to avoid blinking and animations from the default/previous
// state to the current state
val init =
remember(dialogViewModel) {
val torSettings = torSettingsFlow.toSettings()
dialogViewModel.reset(torSettings)
torSettings
}
PrivacyOptionsScreenContents(dialogViewModel, onPost = torSettingsFlow::update, nav)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivacyOptionsScreenContents(
dialogViewModel: TorDialogViewModel,
onPost: (TorSettings) -> Unit,
torSettings: TorSettingsFlow,
nav: INav,
) {
Scaffold(
topBar = {
SavingTopBar(
titleRes = R.string.privacy_options,
onCancel = {
nav.popBack()
},
onPost = {
onPost(dialogViewModel.save())
nav.popBack()
},
)
},
) {
Column(
Modifier
.padding(it)
topBar = { TopBarWithBackButton(stringRes(R.string.privacy_options), nav) },
) { padding ->
PrivacyOptionsContent(torSettings, Modifier.padding(padding))
}
}
// Every control writes straight to [TorSettingsFlow] via `tryEmit`; a debounced collector in
// TorSharedPreferences persists the change automatically, so this screen has no Save/Cancel — the
// back arrow is the only chrome and the state is already saved by the time the user leaves.
@Composable
fun PrivacyOptionsContent(
torSettings: TorSettingsFlow,
modifier: Modifier = Modifier,
) {
val torType by torSettings.torType.collectAsStateWithLifecycle()
Column(
modifier =
modifier
.fillMaxSize()
.verticalScroll(
rememberScrollState(),
).padding(horizontal = 10.dp),
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
SettingsSection(R.string.settings_section_tor_engine) {
TorEngineTile(torSettings, torType)
AnimatedVisibility(
visible = torType == TorType.EXTERNAL,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Column {
SettingsDivider()
OrbotPortTile(torSettings)
}
}
}
AnimatedVisibility(
visible = torType != TorType.OFF,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
PrivacySettingsBody(dialogViewModel)
Column {
Spacer(Modifier.height(20.dp))
SettingsSection(R.string.settings_section_tor_routing) {
PresetTile(torSettings)
SettingsDivider()
TorSwitchTile(torSettings.onionRelaysViaTor, MaterialSymbols.Public, R.string.tor_use_onion_address, R.string.tor_use_onion_address_explainer)
SettingsDivider()
TorSwitchTile(torSettings.dmRelaysViaTor, MaterialSymbols.Mail, R.string.tor_use_dm_relays, R.string.tor_use_dm_relays_explainer)
SettingsDivider()
TorSwitchTile(torSettings.newRelaysViaTor, MaterialSymbols.Dns, R.string.tor_use_new_relays, R.string.tor_use_new_relays_explainer)
SettingsDivider()
TorSwitchTile(torSettings.trustedRelaysViaTor, MaterialSymbols.Shield, R.string.tor_use_trusted_relays, R.string.tor_use_trusted_relays_explainer)
SettingsDivider()
TorSwitchTile(torSettings.moneyOperationsViaTor, MaterialSymbols.CurrencyBitcoin, R.string.tor_use_money_operations, R.string.tor_use_money_operations_explainer)
SettingsDivider()
TorSwitchTile(torSettings.nip05VerificationsViaTor, MaterialSymbols.AlternateEmail, R.string.tor_use_nip05_verification, R.string.tor_use_nip05_verification_explainer)
SettingsDivider()
TorSwitchTile(torSettings.urlPreviewsViaTor, MaterialSymbols.Link, R.string.tor_use_url_previews, R.string.tor_use_url_previews_explainer)
SettingsDivider()
TorSwitchTile(torSettings.imagesViaTor, MaterialSymbols.Image, R.string.tor_use_images, R.string.tor_use_images_explainer)
SettingsDivider()
TorSwitchTile(torSettings.videosViaTor, MaterialSymbols.Videocam, R.string.tor_use_videos, R.string.tor_use_videos_explainer)
SettingsDivider()
TorSwitchTile(torSettings.mediaUploadsViaTor, MaterialSymbols.CloudUpload, R.string.tor_use_media_uploads, R.string.tor_use_media_uploads_explainer)
}
}
}
Spacer(Modifier.height(12.dp))
}
}
@Composable
private fun TorEngineTile(
torSettings: TorSettingsFlow,
torType: TorType,
) {
SegmentedChoiceTile(
icon = MaterialSymbols.Security,
title = R.string.use_internal_tor,
description = R.string.use_internal_tor_explainer,
options = TorType.entries,
labelRes = { it.resourceId },
selected = torType,
onSelect = { torSettings.torType.tryEmit(it) },
)
}
@Composable
private fun OrbotPortTile(torSettings: TorSettingsFlow) {
val port by torSettings.externalSocksPort.collectAsStateWithLifecycle()
// Local text state so the field can hold a transient, mid-typing value; only a value that
// parses to a valid Int is pushed to the flow (and thereby saved).
var text by remember { mutableStateOf(port.toString()) }
SettingsBlockTile(
icon = MaterialSymbols.Dns,
title = stringRes(R.string.orbot_socks_port),
description = stringRes(R.string.connect_through_your_orbot_setup_short),
) {
OutlinedTextField(
value = text,
onValueChange = {
text = it
it.toIntOrNull()?.let { validPort -> torSettings.externalSocksPort.tryEmit(validPort) }
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number,
),
placeholder = {
Text(
text = "9050",
color = MaterialTheme.colorScheme.placeholderText,
)
},
)
}
}
@Composable
private fun PresetTile(torSettings: TorSettingsFlow) {
// propertyWatchFlow re-emits a full TorSettings on every toggle change, so the preset label
// re-derives live (flipping to "Custom" the moment the flags stop matching a named preset).
val current by torSettings.propertyWatchFlow.collectAsStateWithLifecycle(initialValue = torSettings.toSettings())
val preset = whichPreset(current)
var showPicker by remember { mutableStateOf(false) }
SettingsControlRow(
icon = MaterialSymbols.Tune,
title = stringRes(R.string.tor_preset),
description = stringRes(R.string.tor_preset_explainer),
onClick = { showPicker = true },
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringRes(preset.resourceId),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 140.dp),
)
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp).size(20.dp),
)
}
}
if (showPicker) {
val options =
TorPresetType.entries
.map { TitleExplainer(stringRes(it.resourceId), stringRes(it.explainerId)) }
.toImmutableList()
SpinnerSelectionDialog(
options = options,
onDismiss = { showPicker = false },
) { index ->
showPicker = false
torSettings.applyPreset(TorPresetType.entries[index])
}
}
}
@Composable
private fun TorSwitchTile(
flow: MutableStateFlow<Boolean>,
icon: MaterialSymbol,
title: Int,
description: Int,
) {
val checked by flow.collectAsStateWithLifecycle()
SettingsSwitchTile(
icon = icon,
title = title,
description = description,
checked = checked,
onCheckedChange = { flow.tryEmit(it) },
)
}
/**
* Applies a named preset by pushing its per-usage flags into the flow (auto-saving each). Only the
* routing flags are touched — the engine choice and Orbot port are left as the user set them.
* [TorPresetType.CUSTOM] is a no-op: it isn't a preset to apply, it's what the flags read as when
* they match none of the named presets.
*/
private fun TorSettingsFlow.applyPreset(preset: TorPresetType) {
val settings =
when (preset) {
TorPresetType.ONLY_WHEN_NEEDED -> torOnlyWhenNeededPreset
TorPresetType.DEFAULT -> torDefaultPreset
TorPresetType.SMALL_PAYLOADS -> torSmallPayloadsPreset
TorPresetType.FULL_PRIVACY -> torFullyPrivate
TorPresetType.CUSTOM -> return
}
onionRelaysViaTor.tryEmit(settings.onionRelaysViaTor)
dmRelaysViaTor.tryEmit(settings.dmRelaysViaTor)
newRelaysViaTor.tryEmit(settings.newRelaysViaTor)
trustedRelaysViaTor.tryEmit(settings.trustedRelaysViaTor)
urlPreviewsViaTor.tryEmit(settings.urlPreviewsViaTor)
profilePicsViaTor.tryEmit(settings.profilePicsViaTor)
imagesViaTor.tryEmit(settings.imagesViaTor)
videosViaTor.tryEmit(settings.videosViaTor)
moneyOperationsViaTor.tryEmit(settings.moneyOperationsViaTor)
nip05VerificationsViaTor.tryEmit(settings.nip05VerificationsViaTor)
mediaUploadsViaTor.tryEmit(settings.mediaUploadsViaTor)
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun PrivacyOptionsScreenPreview() {
ThemeComparisonRow {
PrivacyOptionsScreen(TorSettingsFlow(), EmptyNav())
}
}
@@ -189,7 +189,7 @@ fun SettingsScreen(
* choices instead of only naming them.
*/
@Composable
private fun <T> SegmentedChoiceTile(
internal fun <T> SegmentedChoiceTile(
icon: MaterialSymbol,
@StringRes title: Int,
@StringRes description: Int,
@@ -20,36 +20,28 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.foundation.layout.Arrangement
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
@Preview
@@ -73,16 +65,15 @@ fun HomeTabsSettingsScreen(
TopBarWithBackButton(stringRes(id = R.string.home_tabs_settings), nav)
},
) { padding ->
Column(Modifier.padding(padding)) {
HomeTabsSettingsContent(accountViewModel)
}
HomeTabsSettingsContent(accountViewModel.settings.uiSettingsFlow, Modifier.padding(padding))
}
}
@Composable
fun HomeTabsSettingsContent(accountViewModel: AccountViewModel) {
val ui = accountViewModel.settings.uiSettingsFlow
fun HomeTabsSettingsContent(
ui: UiSettingsFlow,
modifier: Modifier = Modifier,
) {
val showNewThreads by ui.showHomeNewThreadsTab.collectAsStateWithLifecycle()
val showConversations by ui.showHomeConversationsTab.collectAsStateWithLifecycle()
val showEverything by ui.showHomeEverythingTab.collectAsStateWithLifecycle()
@@ -91,78 +82,37 @@ fun HomeTabsSettingsContent(accountViewModel: AccountViewModel) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Spacer(Modifier.height(16.dp))
Text(
text = stringRes(R.string.home_tabs_settings_description),
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
modifier = Modifier.padding(bottom = 16.dp, start = Size20dp, end = Size20dp),
)
HomeTabSwitchRow(
title = stringRes(R.string.new_threads),
checked = showNewThreads,
// Don't allow disabling the last remaining tab.
enabled = !(showNewThreads && activeCount == 1),
onCheckedChange = {
ui.showHomeNewThreadsTab.tryEmit(it)
},
)
HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp))
HomeTabSwitchRow(
title = stringRes(R.string.conversations),
checked = showConversations,
enabled = !(showConversations && activeCount == 1),
onCheckedChange = {
ui.showHomeConversationsTab.tryEmit(it)
},
)
HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp))
HomeTabSwitchRow(
title = stringRes(R.string.home_tab_everything),
checked = showEverything,
enabled = !(showEverything && activeCount == 1),
onCheckedChange = {
ui.showHomeEverythingTab.tryEmit(it)
},
)
Spacer(Modifier.height(16.dp))
}
}
@Composable
private fun HomeTabSwitchRow(
title: String,
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = Size20dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
)
SettingsSection(R.string.settings_section_home_tabs) {
SettingsSwitchTile(
icon = MaterialSymbols.Forum,
title = R.string.new_threads,
checked = showNewThreads,
// Don't allow disabling the last remaining tab.
enabled = !(showNewThreads && activeCount == 1),
onCheckedChange = { ui.showHomeNewThreadsTab.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Chat,
title = R.string.conversations,
checked = showConversations,
enabled = !(showConversations && activeCount == 1),
onCheckedChange = { ui.showHomeConversationsTab.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Public,
title = R.string.home_tab_everything,
checked = showEverything,
enabled = !(showEverything && activeCount == 1),
onCheckedChange = { ui.showHomeEverythingTab.tryEmit(it) },
)
}
}
}
@@ -20,42 +20,30 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.foundation.layout.Arrangement
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.model.parseGalleryType
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.persistentListOf
@Preview
@Composable
@@ -78,123 +66,77 @@ fun ProfileUiSettingsScreen(
TopBarWithBackButton(stringRes(id = R.string.profile_ui_settings), nav)
},
) { padding ->
Column(Modifier.padding(padding)) {
ProfileUiSettingsContent(accountViewModel)
}
ProfileUiSettingsContent(accountViewModel.settings.uiSettingsFlow, Modifier.padding(padding))
}
}
@Composable
fun ProfileUiSettingsContent(accountViewModel: AccountViewModel) {
val ui = accountViewModel.settings.uiSettingsFlow
fun ProfileUiSettingsContent(
ui: UiSettingsFlow,
modifier: Modifier = Modifier,
) {
val showBadges by ui.showProfileBadges.collectAsStateWithLifecycle()
val showAppRecommendations by ui.showProfileAppRecommendations.collectAsStateWithLifecycle()
val showZapReceived by ui.showProfileZapReceivedFeed.collectAsStateWithLifecycle()
val showFollowers by ui.showProfileFollowersFeed.collectAsStateWithLifecycle()
val showOnchainWallet by ui.showOnchainWallet.collectAsStateWithLifecycle()
val gallery by ui.gallerySet.collectAsStateWithLifecycle()
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Spacer(Modifier.height(16.dp))
Text(
text = stringRes(R.string.profile_ui_settings_description),
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
modifier = Modifier.padding(bottom = 16.dp, start = Size20dp, end = Size20dp),
)
ProfileUiSwitchRow(
title = stringRes(R.string.profile_ui_setting_badges),
checked = showBadges,
onCheckedChange = { ui.showProfileBadges.tryEmit(it) },
)
HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp))
ProfileUiSwitchRow(
title = stringRes(R.string.profile_ui_setting_app_recommendations),
checked = showAppRecommendations,
onCheckedChange = { ui.showProfileAppRecommendations.tryEmit(it) },
)
HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp))
ProfileUiSwitchRow(
title = stringRes(R.string.profile_ui_setting_zap_received_feed),
checked = showZapReceived,
onCheckedChange = { ui.showProfileZapReceivedFeed.tryEmit(it) },
)
HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp))
ProfileUiSwitchRow(
title = stringRes(R.string.profile_ui_setting_followers_feed),
checked = showFollowers,
onCheckedChange = { ui.showProfileFollowersFeed.tryEmit(it) },
)
HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp))
ProfileUiSwitchRow(
title = stringRes(R.string.profile_ui_setting_onchain_wallet),
checked = showOnchainWallet,
onCheckedChange = { ui.showOnchainWallet.tryEmit(it) },
)
HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp))
Column(modifier = Modifier.padding(vertical = 12.dp, horizontal = Size20dp)) {
GalleryChoice(ui)
SettingsSection(R.string.settings_section_profile_sections) {
SettingsSwitchTile(
icon = MaterialSymbols.MilitaryTech,
title = R.string.profile_ui_setting_badges,
checked = showBadges,
onCheckedChange = { ui.showProfileBadges.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Recommend,
title = R.string.profile_ui_setting_app_recommendations,
checked = showAppRecommendations,
onCheckedChange = { ui.showProfileAppRecommendations.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Bolt,
title = R.string.profile_ui_setting_zap_received_feed,
checked = showZapReceived,
onCheckedChange = { ui.showProfileZapReceivedFeed.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Group,
title = R.string.profile_ui_setting_followers_feed,
checked = showFollowers,
onCheckedChange = { ui.showProfileFollowersFeed.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.AccountBalanceWallet,
title = R.string.profile_ui_setting_onchain_wallet,
checked = showOnchainWallet,
onCheckedChange = { ui.showOnchainWallet.tryEmit(it) },
)
}
Spacer(Modifier.height(16.dp))
}
}
@Composable
fun GalleryChoice(sharedPrefs: UiSettingsFlow) {
val galleryItems =
persistentListOf(
TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)),
TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)),
)
val galleryIndex by sharedPrefs.gallerySet.collectAsStateWithLifecycle()
SettingsRow(
R.string.gallery_style,
R.string.gallery_style_description,
galleryItems,
galleryIndex.screenCode,
) {
sharedPrefs.gallerySet.tryEmit(parseGalleryType(it))
}
}
@Composable
private fun ProfileUiSwitchRow(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = Size20dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
)
SettingsSection(R.string.settings_section_appearance) {
SegmentedChoiceTile(
icon = MaterialSymbols.Collections,
title = R.string.gallery_style,
description = R.string.gallery_style_description,
options = ProfileGalleryType.entries,
labelRes = { it.resourceId },
selected = gallery,
onSelect = { ui.gallerySet.tryEmit(it) },
)
}
}
}
@@ -272,6 +272,50 @@ internal fun SettingsSwitchTile(
}
}
/**
* Compact switch row: icon + title + trailing [Switch], with no description line — for
* self-explanatory single-line toggles (e.g. "Profile Badges", "New Threads"). Tapping
* anywhere on the row toggles. When [enabled] is false the row dims and stops responding
* (used to lock the last remaining item in a "keep at least one on" group).
*/
@Composable
internal fun SettingsSwitchTile(
icon: MaterialSymbol,
@StringRes title: Int,
checked: Boolean,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
) {
val alpha = if (enabled) 1f else 0.5f
Row(
modifier =
Modifier
.fillMaxWidth()
.let { if (enabled) it.clickable { onCheckedChange(!checked) } else it }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SettingsIconBox(MaterialTheme.colorScheme.primaryContainer) {
Icon(
symbol = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = alpha),
)
}
Text(
text = stringRes(title),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha),
modifier =
Modifier
.weight(1f)
.padding(start = 16.dp, end = 12.dp),
)
Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange)
}
}
/**
* Sub-row variant of [SettingsControlRow]: indented in place of a leading icon,
* used for controls hierarchically grouped under the row above (e.g. a threshold
+5
View File
@@ -2182,6 +2182,11 @@
<string name="settings_section_appearance">Appearance</string>
<string name="settings_section_media">Media &amp; Data</string>
<string name="settings_section_general">General</string>
<string name="settings_section_tor_engine">Connection</string>
<string name="settings_section_tor_routing">Route through Tor</string>
<string name="settings_section_profile_sections">Sections &amp; feeds</string>
<string name="settings_section_home_tabs">Visible tabs</string>
<string name="settings_section_reminders">Reminders</string>
<string name="wallet_connect">Wallet Connect</string>
<string name="language">Language</string>
<string name="theme">Theme</string>