feat: Composable preference widgets

This commit is contained in:
Tachiyomi Maintainer 2024-05-27 07:24:13 +07:00 committed by Ahmad Ansori Palembani
parent 9046b343de
commit 6887d779ef
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
15 changed files with 1244 additions and 0 deletions

View file

@ -0,0 +1,51 @@
package dev.yokai.presentation.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.Checkbox
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.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import dev.yokai.presentation.theme.Size
@Composable
fun LabeledCheckbox(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Row(
modifier = modifier
.clip(MaterialTheme.shapes.small)
.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(
role = Role.Checkbox,
onClick = {
if (enabled) {
onCheckedChange(!checked)
}
},
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Size.small),
) {
Checkbox(
checked = checked,
onCheckedChange = null,
enabled = enabled,
)
Text(text = label)
}
}

View file

@ -0,0 +1,49 @@
package dev.yokai.presentation.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.yokai.presentation.core.util.clickableNoIndication
import eu.kanade.tachiyomi.data.track.TrackService
@Composable
fun TrackLogoIcon(
tracker: TrackService,
onClick: (() -> Unit)? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
val modifier = if (onClick != null) {
Modifier.clickableNoIndication(
interactionSource = interactionSource,
onClick = onClick
)
} else {
Modifier
}
Box(
modifier = modifier
.size(48.dp)
.background(color = Color(tracker.getLogoColor()), shape = MaterialTheme.shapes.medium)
.padding(4.dp),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(tracker.getLogo()),
contentDescription = stringResource(id = tracker.nameRes()),
)
}
}

View file

@ -0,0 +1,180 @@
package dev.yokai.presentation.component.preference
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import com.google.common.collect.ImmutableList
import com.google.common.collect.ImmutableMap
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.core.preference.Preference as PreferenceData
sealed class Preference {
abstract val title: String
abstract val enabled: Boolean
sealed class PreferenceItem<T> : Preference() {
abstract val subtitle: String?
abstract val icon: ImageVector?
abstract val onValueChanged: suspend (newValue: T) -> Boolean
/**
* A basic [PreferenceItem] that only displays texts.
*/
data class TextPreference(
override val title: String,
override val subtitle: String? = null,
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: String) -> Boolean = { true },
val onClick: (() -> Unit)? = null,
) : PreferenceItem<String>()
/**
* A [PreferenceItem] that provides a two-state toggleable option.
*/
data class SwitchPreference(
val pref: PreferenceData<Boolean>,
override val title: String,
override val subtitle: String? = null,
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: Boolean) -> Boolean = { true },
) : PreferenceItem<Boolean>()
/**
* A [PreferenceItem] that provides a slider to select an integer number.
*/
data class SliderPreference(
val value: Int,
val min: Int = 0,
val max: Int,
override val title: String = "",
override val subtitle: String? = null,
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: Int) -> Boolean = { true },
) : PreferenceItem<Int>()
/**
* A [PreferenceItem] that displays a list of entries as a dialog.
*/
@Suppress("UNCHECKED_CAST")
data class ListPreference<T>(
val pref: PreferenceData<T>,
override val title: String,
override val subtitle: String? = "%s",
val subtitleProvider: @Composable (value: T, entries: ImmutableMap<T, String>) -> String? =
{ v, e -> subtitle?.format(e[v]) },
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: T) -> Boolean = { true },
val entries: ImmutableMap<T, String>,
) : PreferenceItem<T>() {
internal fun internalSet(newValue: Any) = pref.set(newValue as T)
internal suspend fun internalOnValueChanged(newValue: Any) = onValueChanged(newValue as T)
@Composable
internal fun internalSubtitleProvider(value: Any?, entries: ImmutableMap<out Any?, String>) =
subtitleProvider(value as T, entries as ImmutableMap<T, String>)
}
/**
* [ListPreference] but with no connection to a [PreferenceData]
*/
data class BasicListPreference(
val value: String,
override val title: String,
override val subtitle: String? = "%s",
val subtitleProvider: @Composable (value: String, entries: ImmutableMap<String, String>) -> String? =
{ v, e -> subtitle?.format(e[v]) },
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: String) -> Boolean = { true },
val entries: ImmutableMap<String, String>,
) : PreferenceItem<String>()
/**
* A [PreferenceItem] that displays a list of entries as a dialog.
* Multiple entries can be selected at the same time.
*/
data class MultiSelectListPreference(
val pref: PreferenceData<Set<String>>,
override val title: String,
override val subtitle: String? = "%s",
val subtitleProvider: @Composable (
value: Set<String>,
entries: ImmutableMap<String, String>,
) -> String? = { v, e ->
val combined = remember(v) {
v.map { e[it] }
.takeIf { it.isNotEmpty() }
?.joinToString()
} ?: stringResource(R.string.none)
subtitle?.format(combined)
},
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: Set<String>) -> Boolean = { true },
val entries: ImmutableMap<String, String>,
) : PreferenceItem<Set<String>>()
/**
* A [PreferenceItem] that shows a EditText in the dialog.
*/
data class EditTextPreference(
val pref: PreferenceData<String>,
override val title: String,
override val subtitle: String? = "%s",
override val icon: ImageVector? = null,
override val enabled: Boolean = true,
override val onValueChanged: suspend (newValue: String) -> Boolean = { true },
) : PreferenceItem<String>()
/**
* A [PreferenceItem] for individual tracker.
*/
data class TrackerPreference(
val tracker: TrackService,
override val title: String,
val login: () -> Unit,
val logout: () -> Unit,
) : PreferenceItem<String>() {
override val enabled: Boolean = true
override val subtitle: String? = null
override val icon: ImageVector? = null
override val onValueChanged: suspend (newValue: String) -> Boolean = { true }
}
data class InfoPreference(
override val title: String,
) : PreferenceItem<String>() {
override val enabled: Boolean = true
override val subtitle: String? = null
override val icon: ImageVector? = null
override val onValueChanged: suspend (newValue: String) -> Boolean = { true }
}
data class CustomPreference(
override val title: String,
val content: @Composable (PreferenceItem<String>) -> Unit,
) : PreferenceItem<String>() {
override val enabled: Boolean = true
override val subtitle: String? = null
override val icon: ImageVector? = null
override val onValueChanged: suspend (newValue: String) -> Boolean = { true }
}
}
data class PreferenceGroup(
override val title: String,
override val enabled: Boolean = true,
val preferenceItems: ImmutableList<PreferenceItem<out Any>>,
) : Preference()
}

View file

@ -0,0 +1,182 @@
package dev.yokai.presentation.component.preference
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.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.unit.dp
import androidx.glance.text.Text
import dev.yokai.presentation.component.preference.widget.EditTextPreferenceWidget
import dev.yokai.presentation.component.preference.widget.InfoWidget
import dev.yokai.presentation.component.preference.widget.ListPreferenceWidget
import dev.yokai.presentation.component.preference.widget.MultiSelectListPreferenceWidget
import dev.yokai.presentation.component.preference.widget.SwitchPreferenceWidget
import dev.yokai.presentation.component.preference.widget.TextPreferenceWidget
import dev.yokai.presentation.component.preference.widget.TrackingPreferenceWidget
import eu.kanade.tachiyomi.core.preference.collectAsState
import eu.kanade.tachiyomi.data.track.TrackPreferences
import kotlinx.coroutines.launch
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
val LocalPreferenceHighlighted = compositionLocalOf(structuralEqualityPolicy()) { false }
val LocalPreferenceMinHeight = compositionLocalOf(structuralEqualityPolicy()) { 56.dp }
@Composable
fun StatusWrapper(
item: Preference.PreferenceItem<*>,
highlightKey: String?,
content: @Composable () -> Unit,
) {
val enabled = item.enabled
val highlighted = item.title == highlightKey
AnimatedVisibility(
visible = enabled,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
content = {
CompositionLocalProvider(
LocalPreferenceHighlighted provides highlighted,
content = content,
)
},
)
}
@Composable
internal fun PreferenceItem(
item: Preference.PreferenceItem<*>,
highlightKey: String?,
) {
val scope = rememberCoroutineScope()
StatusWrapper(
item = item,
highlightKey = highlightKey,
) {
when (item) {
is Preference.PreferenceItem.SwitchPreference -> {
val value by item.pref.collectAsState()
SwitchPreferenceWidget(
title = item.title,
subtitle = item.subtitle,
icon = item.icon,
checked = value,
onCheckedChanged = { newValue ->
scope.launch {
if (item.onValueChanged(newValue)) {
item.pref.set(newValue)
}
}
},
)
}
is Preference.PreferenceItem.SliderPreference -> {
// TODO: use different composable?
// FIXME: Add the actual thing
Text(text = "Hello World")
/*
SliderItem(
label = item.title,
min = item.min,
max = item.max,
value = item.value,
valueText = item.subtitle.takeUnless { it.isNullOrEmpty() } ?: item.value.toString(),
onChange = {
scope.launch {
item.onValueChanged(it)
}
},
)
*/
}
is Preference.PreferenceItem.ListPreference<*> -> {
val value by item.pref.collectAsState()
ListPreferenceWidget(
value = value,
title = item.title,
subtitle = item.internalSubtitleProvider(value, item.entries),
icon = item.icon,
entries = item.entries,
onValueChange = { newValue ->
scope.launch {
if (item.internalOnValueChanged(newValue!!)) {
item.internalSet(newValue)
}
}
},
)
}
is Preference.PreferenceItem.BasicListPreference -> {
ListPreferenceWidget(
value = item.value,
title = item.title,
subtitle = item.subtitleProvider(item.value, item.entries),
icon = item.icon,
entries = item.entries,
onValueChange = { scope.launch { item.onValueChanged(it) } },
)
}
is Preference.PreferenceItem.MultiSelectListPreference -> {
val values by item.pref.collectAsState()
MultiSelectListPreferenceWidget(
preference = item,
values = values,
onValuesChange = { newValues ->
scope.launch {
if (item.onValueChanged(newValues)) {
item.pref.set(newValues.toMutableSet())
}
}
},
)
}
is Preference.PreferenceItem.TextPreference -> {
TextPreferenceWidget(
title = item.title,
subtitle = item.subtitle,
icon = item.icon,
onPreferenceClick = item.onClick,
)
}
is Preference.PreferenceItem.EditTextPreference -> {
val values by item.pref.collectAsState()
EditTextPreferenceWidget(
title = item.title,
subtitle = item.subtitle,
icon = item.icon,
value = values,
onConfirm = {
val accepted = item.onValueChanged(it)
if (accepted) item.pref.set(it)
accepted
},
)
}
is Preference.PreferenceItem.TrackerPreference -> {
val uName by Injekt.get<TrackPreferences>()
.trackUsername(item.tracker)
.collectAsState()
item.tracker.run {
TrackingPreferenceWidget(
tracker = this,
checked = uName.isNotEmpty(),
onClick = { if (isLogged) item.logout() else item.login() },
)
}
}
is Preference.PreferenceItem.InfoPreference -> {
InfoWidget(text = item.title)
}
is Preference.PreferenceItem.CustomPreference -> {
item.content(item)
}
}
}
}

View file

@ -0,0 +1,125 @@
package dev.yokai.presentation.component.preference.widget
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.StartOffset
import androidx.compose.animation.core.StartOffsetType
import androidx.compose.animation.core.repeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.composed
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.yokai.presentation.component.preference.LocalPreferenceHighlighted
import dev.yokai.presentation.component.preference.LocalPreferenceMinHeight
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@Composable
internal fun BasePreferenceWidget(
modifier: Modifier = Modifier,
title: String? = null,
subcomponent: @Composable (ColumnScope.() -> Unit)? = null,
icon: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
widget: @Composable (() -> Unit)? = null,
) {
val highlighted = LocalPreferenceHighlighted.current
val minHeight = LocalPreferenceMinHeight.current
Row(
modifier = modifier
.highlightBackground(highlighted)
.sizeIn(minHeight = minHeight)
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
Box(
modifier = Modifier.padding(start = PrefsHorizontalPadding, end = 8.dp),
content = { icon() },
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = PrefsVerticalPadding),
) {
if (!title.isNullOrBlank()) {
Text(
modifier = Modifier.padding(horizontal = PrefsHorizontalPadding),
text = title,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
style = MaterialTheme.typography.titleLarge,
fontSize = TitleFontSize,
)
}
subcomponent?.invoke(this)
}
if (widget != null) {
Box(
modifier = Modifier.padding(end = PrefsHorizontalPadding),
content = { widget() },
)
}
}
}
internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier = composed {
var highlightFlag by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (highlighted) {
highlightFlag = true
delay(3.seconds)
highlightFlag = false
}
}
val highlight by animateColorAsState(
targetValue = if (highlightFlag) {
MaterialTheme.colorScheme.surfaceTint.copy(alpha = .12f)
} else {
Color.Transparent
},
animationSpec = if (highlightFlag) {
repeatable(
iterations = 5,
animation = tween(durationMillis = 200),
repeatMode = RepeatMode.Reverse,
initialStartOffset = StartOffset(
offsetMillis = 600,
offsetType = StartOffsetType.Delay,
),
)
} else {
tween(200)
},
label = "highlight",
)
this.background(color = highlight)
}
internal val TrailingWidgetBuffer = 16.dp
internal val PrefsHorizontalPadding = 16.dp
internal val PrefsVerticalPadding = 16.dp
internal val TitleFontSize = 16.sp

View file

@ -0,0 +1,97 @@
package dev.yokai.presentation.component.preference.widget
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.window.DialogProperties
import eu.kanade.tachiyomi.R
import kotlinx.coroutines.launch
@Composable
fun EditTextPreferenceWidget(
title: String,
subtitle: String?,
icon: ImageVector?,
value: String,
onConfirm: suspend (String) -> Boolean,
) {
var isDialogShown by remember { mutableStateOf(false) }
TextPreferenceWidget(
title = title,
subtitle = subtitle?.format(value),
icon = icon,
onPreferenceClick = { isDialogShown = true },
)
if (isDialogShown) {
val scope = rememberCoroutineScope()
val onDismissRequest = { isDialogShown = false }
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(value))
}
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = title) },
text = {
OutlinedTextField(
value = textFieldValue,
onValueChange = { textFieldValue = it },
trailingIcon = {
if (textFieldValue.text.isBlank()) {
Icon(imageVector = Icons.Filled.Error, contentDescription = null)
} else {
IconButton(onClick = { textFieldValue = TextFieldValue("") }) {
Icon(imageVector = Icons.Filled.Cancel, contentDescription = null)
}
}
},
isError = textFieldValue.text.isBlank(),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
properties = DialogProperties(
usePlatformDefaultWidth = true,
),
confirmButton = {
TextButton(
enabled = textFieldValue.text != value && textFieldValue.text.isNotBlank(),
onClick = {
scope.launch {
if (onConfirm(textFieldValue.text)) {
onDismissRequest()
}
}
},
) {
Text(text = stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}
}

View file

@ -0,0 +1,36 @@
package dev.yokai.presentation.component.preference.widget
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.yokai.presentation.core.util.secondaryItemAlpha
import dev.yokai.presentation.theme.Size
@Composable
internal fun InfoWidget(text: String) {
Column(
modifier = Modifier
.padding(
horizontal = PrefsHorizontalPadding,
vertical = Size.medium,
)
.secondaryItemAlpha(),
verticalArrangement = Arrangement.spacedBy(Size.medium),
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
)
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
)
}
}

View file

@ -0,0 +1,110 @@
package dev.yokai.presentation.component.preference.widget
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.minimumInteractiveComponentSize
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.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@Composable
fun <T> ListPreferenceWidget(
value: T,
title: String,
subtitle: String?,
icon: ImageVector?,
entries: Map<out T, String>,
onValueChange: (T) -> Unit,
) {
var isDialogShown by remember { mutableStateOf(false) }
TextPreferenceWidget(
title = title,
subtitle = subtitle,
icon = icon,
onPreferenceClick = { isDialogShown = true },
)
if (isDialogShown) {
AlertDialog(
onDismissRequest = { isDialogShown = false },
title = { Text(text = title) },
text = {
Box {
val state = rememberLazyListState()
LazyColumn(state = state) {
entries.forEach { current ->
val isSelected = value == current.key
item {
DialogRow(
label = current.value,
isSelected = isSelected,
onSelected = {
onValueChange(current.key!!)
isDialogShown = false
},
)
}
}
}
if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
}
},
confirmButton = {
TextButton(onClick = { isDialogShown = false }) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}
}
@Composable
private fun DialogRow(
label: String,
isSelected: Boolean,
onSelected: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.selectable(
selected = isSelected,
onClick = { if (!isSelected) onSelected() },
)
.fillMaxWidth()
.minimumInteractiveComponentSize(),
) {
RadioButton(
selected = isSelected,
onClick = null,
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge.merge(),
modifier = Modifier.padding(start = 24.dp),
)
}
}

View file

@ -0,0 +1,82 @@
package dev.yokai.presentation.component.preference.widget
import androidx.compose.material.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.runtime.toMutableStateList
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import androidx.glance.appwidget.lazy.LazyColumn
import dev.yokai.presentation.component.LabeledCheckbox
import dev.yokai.presentation.component.preference.Preference
@Composable
fun MultiSelectListPreferenceWidget(
preference: Preference.PreferenceItem.MultiSelectListPreference,
values: Set<String>,
onValuesChange: (Set<String>) -> Unit,
) {
var isDialogShown by remember { mutableStateOf(false) }
TextPreferenceWidget(
title = preference.title,
subtitle = preference.subtitleProvider(values, preference.entries),
icon = preference.icon,
onPreferenceClick = { isDialogShown = true },
)
if (isDialogShown) {
val selected = remember {
preference.entries.keys
.filter { values.contains(it) }
.toMutableStateList()
}
AlertDialog(
onDismissRequest = { isDialogShown = false },
title = { Text(text = preference.title) },
text = {
LazyColumn {
preference.entries.forEach { current ->
item {
val isSelected = selected.contains(current.key)
LabeledCheckbox(
label = current.value,
checked = isSelected,
onCheckedChange = {
if (it) {
selected.add(current.key)
} else {
selected.remove(current.key)
}
},
)
}
}
}
},
properties = DialogProperties(
usePlatformDefaultWidth = true,
),
confirmButton = {
TextButton(
onClick = {
onValuesChange(selected.toMutableSet())
isDialogShown = false
},
) {
Text(text = stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = { isDialogShown = false }) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}
}

View file

@ -0,0 +1,28 @@
package dev.yokai.presentation.component.preference.widget
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
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.unit.dp
@Composable
fun PreferenceGroupHeader(title: String) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp, top = 14.dp),
) {
Text(
text = title,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(horizontal = PrefsHorizontalPadding),
style = MaterialTheme.typography.bodyMedium,
)
}
}

View file

@ -0,0 +1,32 @@
package dev.yokai.presentation.component.preference.widget
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun SwitchPreferenceWidget(
modifier: Modifier = Modifier,
title: String,
subtitle: String? = null,
icon: ImageVector? = null,
checked: Boolean = false,
onCheckedChanged: (Boolean) -> Unit,
) {
TextPreferenceWidget(
modifier = modifier,
title = title,
subtitle = subtitle,
icon = icon,
widget = {
Switch(
checked = checked,
onCheckedChange = null,
modifier = Modifier.padding(start = TrailingWidgetBuffer),
)
},
onPreferenceClick = { onCheckedChanged(!checked) },
)
}

View file

@ -0,0 +1,54 @@
package dev.yokai.presentation.component.preference.widget
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import dev.yokai.presentation.core.util.secondaryItemAlpha
@Composable
fun TextPreferenceWidget(
modifier: Modifier = Modifier,
title: String? = null,
subtitle: String? = null,
icon: ImageVector? = null,
iconTint: Color = MaterialTheme.colorScheme.primary,
widget: @Composable (() -> Unit)? = null,
onPreferenceClick: (() -> Unit)? = null,
) {
BasePreferenceWidget(
modifier = modifier,
title = title,
subcomponent = if (!subtitle.isNullOrBlank()) {
{
Text(
text = subtitle,
modifier = Modifier
.padding(horizontal = PrefsHorizontalPadding)
.secondaryItemAlpha(),
style = MaterialTheme.typography.bodySmall,
maxLines = 10,
)
}
} else {
null
},
icon = if (icon != null) {
{
Icon(
imageVector = icon,
tint = iconTint,
contentDescription = null,
)
}
} else {
null
},
onClick = onPreferenceClick,
widget = widget,
)
}

View file

@ -0,0 +1,63 @@
package dev.yokai.presentation.component.preference.widget
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Done
import androidx.compose.material3.Icon
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.graphics.Color
import androidx.compose.ui.unit.dp
import dev.yokai.presentation.component.TrackLogoIcon
import dev.yokai.presentation.component.preference.LocalPreferenceHighlighted
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.appwidget.util.stringResource
import eu.kanade.tachiyomi.data.track.TrackService
@Composable
fun TrackingPreferenceWidget(
modifier: Modifier = Modifier,
tracker: TrackService,
checked: Boolean,
onClick: (() -> Unit)? = null,
) {
val highlighted = LocalPreferenceHighlighted.current
Box(modifier = Modifier.highlightBackground(highlighted)) {
Row(
modifier = modifier
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
.fillMaxWidth()
.padding(horizontal = PrefsHorizontalPadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TrackLogoIcon(tracker)
Text(
text = stringResource(id = tracker.nameRes()),
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp),
maxLines = 1,
style = MaterialTheme.typography.titleLarge,
fontSize = TitleFontSize,
)
if (checked) {
Icon(
imageVector = Icons.Outlined.Done,
modifier = Modifier
.padding(4.dp)
.size(32.dp),
tint = Color(0xFF4CAF50),
contentDescription = stringResource(R.string.successfully_logged_in),
)
}
}
}
}

View file

@ -0,0 +1,142 @@
package dev.yokai.presentation.component.preference.widget
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckBox
import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank
import androidx.compose.material.icons.rounded.DisabledByDefault
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R
private enum class State {
CHECKED, INVERSED, UNCHECKED
}
@Composable
fun <T> TriStateListDialog(
title: String,
message: String? = null,
items: List<T>,
initialChecked: List<T>,
initialInversed: List<T>,
itemLabel: @Composable (T) -> String,
onDismissRequest: () -> Unit,
onValueChanged: (newIncluded: List<T>, newExcluded: List<T>) -> Unit,
) {
val selected = remember {
items
.map {
when (it) {
in initialChecked -> State.CHECKED
in initialInversed -> State.INVERSED
else -> State.UNCHECKED
}
}
.toMutableStateList()
}
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = title) },
text = {
Column {
if (message != null) {
Text(
text = message,
modifier = Modifier.padding(bottom = 8.dp),
)
}
Box {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
itemsIndexed(items = items) { index, item ->
val state = selected[index]
Row(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.clickable {
selected[index] = when (state) {
State.UNCHECKED -> State.CHECKED
State.CHECKED -> State.INVERSED
State.INVERSED -> State.UNCHECKED
}
}
.defaultMinSize(minHeight = 48.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.padding(end = 20.dp),
imageVector = when (state) {
State.UNCHECKED -> Icons.Rounded.CheckBoxOutlineBlank
State.CHECKED -> Icons.Rounded.CheckBox
State.INVERSED -> Icons.Rounded.DisabledByDefault
},
tint = if (state == State.UNCHECKED) {
LocalContentColor.current
} else {
MaterialTheme.colorScheme.primary
},
contentDescription = stringResource(
when (state) {
State.UNCHECKED -> R.string.not_selected
State.CHECKED -> R.string.selected
State.INVERSED -> R.string.disabled
},
),
)
Text(text = itemLabel(item))
}
}
}
if (listState.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
if (listState.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
}
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(android.R.string.cancel))
}
},
confirmButton = {
TextButton(
onClick = {
val included = items.mapIndexedNotNull { index, category ->
if (selected[index] == State.CHECKED) category else null
}
val excluded = items.mapIndexedNotNull { index, category ->
if (selected[index] == State.INVERSED) category else null
}
onValueChanged(included, excluded)
},
) {
Text(text = stringResource(android.R.string.ok))
}
},
)
}

View file

@ -1,7 +1,20 @@
package dev.yokai.presentation.core.util
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import dev.yokai.presentation.theme.SecondaryItemAlpha
fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha)
fun Modifier.clickableNoIndication(
interactionSource: MutableInteractionSource,
onLongClick: (() -> Unit)? = null,
onClick: () -> Unit,
) = this.combinedClickable(
interactionSource = interactionSource,
indication = null,
onLongClick = onLongClick,
onClick = onClick,
)