From 6887d779efd87b5711508eb44d35643135776a64 Mon Sep 17 00:00:00 2001 From: Tachiyomi Maintainer Date: Mon, 27 May 2024 07:24:13 +0700 Subject: [PATCH] feat: Composable preference widgets --- .../presentation/component/LabeledCheckbox.kt | 51 +++++ .../presentation/component/TrackLogoIcon.kt | 49 +++++ .../component/preference/Preference.kt | 180 +++++++++++++++++ .../component/preference/PreferenceItem.kt | 182 ++++++++++++++++++ .../preference/widget/BasePreferenceWidget.kt | 125 ++++++++++++ .../widget/EditTextPreferenceWidget.kt | 97 ++++++++++ .../component/preference/widget/InfoWidget.kt | 36 ++++ .../preference/widget/ListPreferenceWidget.kt | 110 +++++++++++ .../widget/MultiListPreferenceWidget.kt | 82 ++++++++ .../widget/PreferenceGroupHeader.kt | 28 +++ .../widget/SwitchPreferenceWidget.kt | 32 +++ .../preference/widget/TextPreferenceWidget.kt | 54 ++++++ .../widget/TrackingPreferenceWidget.kt | 63 ++++++ .../preference/widget/TriStateListDialog.kt | 142 ++++++++++++++ .../core/util/ModifierExtensions.kt | 13 ++ 15 files changed, 1244 insertions(+) create mode 100644 app/src/main/java/dev/yokai/presentation/component/LabeledCheckbox.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/TrackLogoIcon.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/Preference.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/PreferenceItem.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/BasePreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/EditTextPreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/InfoWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/ListPreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/MultiListPreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/PreferenceGroupHeader.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/SwitchPreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/TextPreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/TrackingPreferenceWidget.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/preference/widget/TriStateListDialog.kt diff --git a/app/src/main/java/dev/yokai/presentation/component/LabeledCheckbox.kt b/app/src/main/java/dev/yokai/presentation/component/LabeledCheckbox.kt new file mode 100644 index 0000000000..6ab18fe06d --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/LabeledCheckbox.kt @@ -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) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/TrackLogoIcon.kt b/app/src/main/java/dev/yokai/presentation/component/TrackLogoIcon.kt new file mode 100644 index 0000000000..cc18fac295 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/TrackLogoIcon.kt @@ -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()), + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/Preference.kt b/app/src/main/java/dev/yokai/presentation/component/preference/Preference.kt new file mode 100644 index 0000000000..f997787b73 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/Preference.kt @@ -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 : 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() + + /** + * A [PreferenceItem] that provides a two-state toggleable option. + */ + data class SwitchPreference( + val pref: PreferenceData, + 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() + + /** + * 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() + + /** + * A [PreferenceItem] that displays a list of entries as a dialog. + */ + @Suppress("UNCHECKED_CAST") + data class ListPreference( + val pref: PreferenceData, + override val title: String, + override val subtitle: String? = "%s", + val subtitleProvider: @Composable (value: T, entries: ImmutableMap) -> 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, + ) : PreferenceItem() { + 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) = + subtitleProvider(value as T, entries as ImmutableMap) + } + + /** + * [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? = + { 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, + ) : PreferenceItem() + + /** + * 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>, + override val title: String, + override val subtitle: String? = "%s", + val subtitleProvider: @Composable ( + value: Set, + entries: ImmutableMap, + ) -> 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) -> Boolean = { true }, + + val entries: ImmutableMap, + ) : PreferenceItem>() + + /** + * A [PreferenceItem] that shows a EditText in the dialog. + */ + data class EditTextPreference( + val pref: PreferenceData, + 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() + + /** + * A [PreferenceItem] for individual tracker. + */ + data class TrackerPreference( + val tracker: TrackService, + override val title: String, + val login: () -> Unit, + val logout: () -> Unit, + ) : PreferenceItem() { + 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() { + 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) -> Unit, + ) : PreferenceItem() { + 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>, + ) : Preference() +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/PreferenceItem.kt b/app/src/main/java/dev/yokai/presentation/component/preference/PreferenceItem.kt new file mode 100644 index 0000000000..ab94e5eb90 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/PreferenceItem.kt @@ -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() + .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) + } + } + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/BasePreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/BasePreferenceWidget.kt new file mode 100644 index 0000000000..62c9bda989 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/BasePreferenceWidget.kt @@ -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 diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/EditTextPreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/EditTextPreferenceWidget.kt new file mode 100644 index 0000000000..a2472d23f2 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/EditTextPreferenceWidget.kt @@ -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)) + } + }, + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/InfoWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/InfoWidget.kt new file mode 100644 index 0000000000..f09b27483c --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/InfoWidget.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/ListPreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/ListPreferenceWidget.kt new file mode 100644 index 0000000000..9524bc4c29 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/ListPreferenceWidget.kt @@ -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 ListPreferenceWidget( + value: T, + title: String, + subtitle: String?, + icon: ImageVector?, + entries: Map, + 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), + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/MultiListPreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/MultiListPreferenceWidget.kt new file mode 100644 index 0000000000..d5b23960dd --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/MultiListPreferenceWidget.kt @@ -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, + onValuesChange: (Set) -> 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)) + } + }, + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/PreferenceGroupHeader.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/PreferenceGroupHeader.kt new file mode 100644 index 0000000000..f78b0b1cde --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/PreferenceGroupHeader.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/SwitchPreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/SwitchPreferenceWidget.kt new file mode 100644 index 0000000000..8a66912363 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/SwitchPreferenceWidget.kt @@ -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) }, + ) +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/TextPreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/TextPreferenceWidget.kt new file mode 100644 index 0000000000..cf5662c482 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/TextPreferenceWidget.kt @@ -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, + ) +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/TrackingPreferenceWidget.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/TrackingPreferenceWidget.kt new file mode 100644 index 0000000000..76db0502ba --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/TrackingPreferenceWidget.kt @@ -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), + ) + } + } + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/preference/widget/TriStateListDialog.kt b/app/src/main/java/dev/yokai/presentation/component/preference/widget/TriStateListDialog.kt new file mode 100644 index 0000000000..c7579ed825 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/preference/widget/TriStateListDialog.kt @@ -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 TriStateListDialog( + title: String, + message: String? = null, + items: List, + initialChecked: List, + initialInversed: List, + itemLabel: @Composable (T) -> String, + onDismissRequest: () -> Unit, + onValueChanged: (newIncluded: List, newExcluded: List) -> 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)) + } + }, + ) +} diff --git a/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt b/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt index 7516806c28..81c98b2887 100644 --- a/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt +++ b/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt @@ -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, +)