Add PreferencesScreen

Three layers :
- DataStore implementation
- Preference description
- UI Preference composables
This commit is contained in:
Shinokuni 2024-07-13 17:03:04 +02:00
parent 76dfbeff32
commit da5b030ab6
13 changed files with 553 additions and 9 deletions

View File

@ -57,6 +57,7 @@ dependencies {
implementation(libs.palette)
implementation(libs.workmanager)
implementation(libs.encrypted.preferences)
implementation(libs.datastore)
implementation(libs.jsoup)
implementation(libs.jodatime)

View File

@ -1,5 +1,11 @@
package com.readrops.app
import android.content.Context
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.readrops.api.services.Credentials
@ -16,8 +22,13 @@ import com.readrops.app.repositories.GetFoldersWithFeeds
import com.readrops.app.repositories.LocalRSSRepository
import com.readrops.app.repositories.NextcloudNewsRepository
import com.readrops.app.timelime.TimelineScreenModel
import com.readrops.app.util.DataStorePreferences
import com.readrops.app.util.Preferences
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.android.ext.koin.androidContext
import org.koin.core.parameter.parametersOf
import org.koin.dsl.module
@ -72,4 +83,19 @@ val composeAppModule = module {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
single {
PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(
produceNewData = { emptyPreferences() }
),
migrations = listOf(SharedPreferencesMigration(get(),"settings")),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
produceFile = { get<Context>().preferencesDataStoreFile("settings") }
)
}
single { DataStorePreferences(get()) }
single { Preferences(get()) }
}

View File

@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
@ -26,6 +27,7 @@ import cafe.adriel.voyager.navigator.tab.TabOptions
import com.readrops.app.BuildConfig
import com.readrops.app.R
import com.readrops.app.account.selection.adaptiveIconPainterResource
import com.readrops.app.more.preferences.PreferencesScreen
import com.readrops.app.util.components.IconText
import com.readrops.app.util.components.SelectableIconText
import com.readrops.app.util.openUrl
@ -33,8 +35,10 @@ import com.readrops.app.util.theme.LargeSpacer
import com.readrops.app.util.theme.MediumSpacer
import com.readrops.app.util.theme.ShortSpacer
import com.readrops.app.util.theme.spacing
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
object MoreTab : Tab {
object MoreTab : Tab, KoinComponent {
override val options: TabOptions
@Composable
@ -127,32 +131,35 @@ object MoreTab : Tab {
}
}
LargeSpacer()
MediumSpacer()
SelectableIconText(
icon = painterResource(id = R.drawable.ic_settings),
text = stringResource(R.string.settings),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
spacing = MaterialTheme.spacing.largeSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
onClick = { }
tint = MaterialTheme.colorScheme.primary,
onClick = { navigator.push(PreferencesScreen(get())) }
)
SelectableIconText(
icon = painterResource(id = R.drawable.ic_library),
text = stringResource(id = R.string.open_source_libraries),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
spacing = MaterialTheme.spacing.largeSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
tint = MaterialTheme.colorScheme.primary,
onClick = { navigator.push(AboutLibrariesScreen()) }
)
SelectableIconText(
icon = painterResource(id = R.drawable.ic_donation),
text = stringResource(id = R.string.make_donation),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
spacing = MaterialTheme.spacing.largeSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
tint = MaterialTheme.colorScheme.primary,
onClick = { }
)
}

View File

@ -0,0 +1,114 @@
package com.readrops.app.more.preferences
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.readrops.app.R
import com.readrops.app.more.preferences.components.ListPreferenceWidget
import com.readrops.app.more.preferences.components.PreferenceHeader
import com.readrops.app.more.preferences.components.SwitchPreferenceWidget
import com.readrops.app.util.Preferences
import com.readrops.app.util.components.AndroidScreen
import org.koin.core.component.KoinComponent
class PreferencesScreen(val preferences: Preferences) : AndroidScreen(), KoinComponent {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringResource(id = R.string.preferences)) },
navigationIcon = {
IconButton(
onClick = { navigator.pop() }
) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = null
)
}
}
)
}
) { paddingValues ->
Box(
modifier = Modifier.padding(paddingValues)
) {
Column {
PreferenceHeader(text = stringResource(id = R.string.global))
ListPreferenceWidget(
preference = preferences.theme,
entries = mapOf(
"light" to stringResource(id = R.string.light),
"dark" to stringResource(id = R.string.dark),
"system" to stringResource(id = R.string.system)
),
title = stringResource(id = R.string.theme),
onValueChange = {}
)
ListPreferenceWidget(
preference = preferences.backgroundSynchronization,
entries = mapOf(
"manual" to stringResource(id = R.string.manual),
"0.30" to stringResource(id = R.string.min_30),
"1" to stringResource(id = R.string.hour_1),
"2" to stringResource(id = R.string.hour_2),
"3" to stringResource(id = R.string.hour_3),
"6" to stringResource(id = R.string.hour_6),
"12" to stringResource(id = R.string.hour_12),
"24" to stringResource(id = R.string.every_day)
),
title = stringResource(id = R.string.auto_synchro),
onValueChange = {}
)
PreferenceHeader(text = "Timeline")
SwitchPreferenceWidget(
preference = preferences.hideReadFeeds,
title = stringResource(id = R.string.hide_feeds),
subtitle = "Feeds with no left unread items will be hidden with their respective folder"
)
SwitchPreferenceWidget(
preference = preferences.scrollRead,
title = stringResource(id = R.string.mark_items_read)
)
PreferenceHeader(text = "Item view")
ListPreferenceWidget(
preference = preferences.openLinksWith,
entries = mapOf(
"navigator_view" to stringResource(id = R.string.navigator_view),
"external_navigator" to stringResource(id = R.string.external_navigator)
),
title = stringResource(id = R.string.open_items_in),
onValueChange = {}
)
}
}
}
}
}

View File

@ -0,0 +1,63 @@
package com.readrops.app.more.preferences.components
import androidx.compose.foundation.clickable
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.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import com.readrops.app.util.theme.MediumSpacer
import com.readrops.app.util.theme.spacing
@Composable
fun BasePreference(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
rightComponent: (@Composable () -> Unit)? = null
) {
Box(
modifier = modifier.clickable { onClick() }
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Normal,
maxLines = 2
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
if (rightComponent != null) {
MediumSpacer()
rightComponent()
}
}
}
}

View File

@ -0,0 +1,62 @@
package com.readrops.app.more.preferences.components
import androidx.compose.runtime.Composable
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.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.readrops.app.util.Preference
import kotlinx.coroutines.launch
@Composable
fun <T> ListPreferenceWidget(
preference: Preference<T>,
entries: Map<T, String>,
title: String,
modifier: Modifier = Modifier,
onValueChange: (T) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
var showDialog by remember { mutableStateOf(false) }
val selectedKey by preference.flow.collectAsStateWithLifecycle(initialValue = preference.default)
if (showDialog) {
val values = remember {
entries.map { entry ->
ToggleableInfo(
key = entry.key,
text = entry.value,
isSelected = selectedKey == entry.key
)
}.toMutableStateList()
}
RadioButtonPreferenceDialog(
title = title,
entries = values,
onCheckChange = { newKey ->
onValueChange(newKey)
values.replaceAll {
it.copy(isSelected = it.key == newKey)
}
coroutineScope.launch {
preference.write(newKey)
}
},
onDismiss = { showDialog = false }
)
}
BasePreference(
title = title,
subtitle = entries[selectedKey],
onClick = { showDialog = true },
modifier = modifier
)
}

View File

@ -0,0 +1,20 @@
package com.readrops.app.more.preferences.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.readrops.app.util.theme.spacing
@Composable
fun PreferenceHeader(
text: String
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
)
}

View File

@ -0,0 +1,133 @@
package com.readrops.app.more.preferences.components
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.readrops.app.R
import com.readrops.app.util.theme.LargeSpacer
import com.readrops.app.util.theme.MediumSpacer
import com.readrops.app.util.theme.spacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PreferenceBaseDialog(
title: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
BasicAlertDialog(
onDismissRequest = onDismiss
) {
Surface(
tonalElevation = AlertDialogDefaults.TonalElevation,
shape = AlertDialogDefaults.shape,
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
modifier = modifier
.padding(MaterialTheme.spacing.largeSpacing)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = AlertDialogDefaults.titleContentColor
)
MediumSpacer()
content()
}
}
}
}
data class ToggleableInfo<T>(
val key: T,
val text: String,
val isSelected: Boolean
)
@Composable
fun <T> RadioButtonPreferenceDialog(
title: String,
entries: List<ToggleableInfo<T>>,
onCheckChange: (T) -> Unit,
onDismiss: () -> Unit
) {
PreferenceBaseDialog(
title = title,
onDismiss = onDismiss
) {
Column(
horizontalAlignment = Alignment.Start
) {
entries.forEach { entry ->
RadioButtonItem(
text = entry.text,
isSelected = entry.isSelected,
onClick = { onCheckChange(entry.key) }
)
}
MediumSpacer()
TextButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.End)
) {
Text(text = stringResource(id = R.string.cancel))
}
}
}
}
@Composable
fun RadioButtonItem(
text: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Box(
modifier = Modifier.clickable { onClick() }
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = MaterialTheme.spacing.shortSpacing,
vertical = MaterialTheme.spacing.veryShortSpacing
)
) {
RadioButton(
selected = isSelected,
onClick = onClick
)
LargeSpacer()
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}

View File

@ -0,0 +1,42 @@
package com.readrops.app.more.preferences.components
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import com.readrops.app.util.Preference
import kotlinx.coroutines.launch
@Composable
fun SwitchPreferenceWidget(
preference: Preference<Boolean>,
title: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
) {
val isChecked by preference.flow.collectAsState(initial = preference.default)
val coroutineScope = rememberCoroutineScope()
BasePreference(
title = title,
subtitle = subtitle,
onClick = {
coroutineScope.launch {
preference.write(!isChecked)
}
},
rightComponent = {
Switch(
checked = isChecked,
onCheckedChange = {
coroutineScope.launch {
preference.write(!isChecked)
}
}
)
},
modifier = modifier
)
}

View File

@ -0,0 +1,73 @@
package com.readrops.app.util
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
data class Preference<T>(
val dataStore: DataStorePreferences,
val key: Preferences.Key<T>,
val default: T,
val flow: Flow<T> = dataStore.read(key, default)
) {
suspend fun write(value: T) {
dataStore.write(key, value)
}
}
class Preferences(
dataStore: DataStorePreferences,
) {
val theme = Preference(
dataStore = dataStore,
key = stringPreferencesKey("theme"),
default = "system"
)
val backgroundSynchronization = Preference(
dataStore = dataStore,
key = stringPreferencesKey("synchro"),
default = "manual"
)
val scrollRead = Preference(
dataStore = dataStore,
key = booleanPreferencesKey("scroll_read"),
default = false
)
val hideReadFeeds = Preference(
dataStore = dataStore,
key = booleanPreferencesKey("hide_read_feeds"),
default = false
)
val openLinksWith = Preference(
dataStore = dataStore,
key = stringPreferencesKey("open_links_with"),
default = "navigator_view"
)
}
class DataStorePreferences(private val dataStore: DataStore<Preferences>) {
fun <T> read(key: Preferences.Key<T>, default: T): Flow<T> {
return dataStore.data
.map { it[key] ?: default }
.distinctUntilChanged()
}
suspend fun <T> write(key: Preferences.Key<T>, value: T) {
dataStore.edit { settings ->
settings[key] = value
}
}
}

View File

@ -176,4 +176,5 @@
<string name="name">Nom</string>
<string name="url">URL</string>
<string name="unread">%1$d non-lu(s)</string>
<string name="preferences">Paramètres</string>
</resources>

View File

@ -185,4 +185,5 @@
<string name="name">Name</string>
<string name="url">URL</string>
<string name="unread">%1$d unread</string>
<string name="preferences">Preferences</string>
</resources>

View File

@ -94,6 +94,7 @@ material = "com.google.android.material:material:1.12.0"
palette = "androidx.palette:palette-ktx:1.0.0"
workmanager = "androidx.work:work-runtime-ktx:2.9.0"
encrypted-preferences = "androidx.security:security-crypto:1.1.0-alpha06"
datastore = "androidx.datastore:datastore-preferences:1.1.1"
# test
junit4 = "junit:junit:4.13.2"