diff --git a/.github/renovate.json b/.github/renovate.json index 6abe04a22..3054832f9 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -6,7 +6,6 @@ "ignoreDeps": [ "androidx.core:core-splashscreen", "com.android.tools:r8", - "com.google.guava:guava", - "com.github.commandiron:WheelPickerCompose" + "com.google.guava:guava" ] } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4b5e2c263..1482cd772 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -241,7 +241,6 @@ dependencies { implementation(libs.aboutLibraries.compose) implementation(libs.cascade) implementation(libs.bundles.voyager) - implementation(libs.wheelpicker) implementation(libs.materialmotion.core) // Logging diff --git a/app/src/main/java/eu/kanade/presentation/components/WheelPicker.kt b/app/src/main/java/eu/kanade/presentation/components/WheelPicker.kt new file mode 100644 index 000000000..7f5c731fd --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/WheelPicker.kt @@ -0,0 +1,286 @@ +package eu.kanade.presentation.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +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.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.util.padding +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import java.text.DateFormatSymbols +import java.time.LocalDate +import kotlin.math.absoluteValue + +@Composable +fun WheelPicker( + modifier: Modifier = Modifier, + startIndex: Int = 0, + count: Int, + size: DpSize = DpSize(128.dp, 128.dp), + onSelectionChanged: (index: Int) -> Unit = {}, + backgroundContent: (@Composable (size: DpSize) -> Unit)? = { + WheelPickerDefaults.Background(size = it) + }, + itemContent: @Composable LazyItemScope.(index: Int) -> Unit, +) { + val lazyListState = rememberLazyListState(startIndex) + + LaunchedEffect(lazyListState, onSelectionChanged) { + snapshotFlow { lazyListState.firstVisibleItemScrollOffset } + .map { calculateSnappedItemIndex(lazyListState) } + .distinctUntilChanged() + .collectLatest { + onSelectionChanged(it) + } + } + + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + backgroundContent?.invoke(size) + + LazyColumn( + modifier = Modifier + .height(size.height) + .width(size.width), + state = lazyListState, + contentPadding = PaddingValues(vertical = size.height / RowCount * ((RowCount - 1) / 2)), + flingBehavior = rememberSnapFlingBehavior(lazyListState = lazyListState), + ) { + items(count) { index -> + Box( + modifier = Modifier + .height(size.height / RowCount) + .width(size.width) + .alpha( + calculateAnimatedAlpha( + lazyListState = lazyListState, + index = index, + ), + ), + contentAlignment = Alignment.Center, + ) { + itemContent(index) + } + } + } + } +} + +@Composable +fun WheelTextPicker( + modifier: Modifier = Modifier, + startIndex: Int = 0, + texts: List, + size: DpSize = DpSize(128.dp, 128.dp), + onSelectionChanged: (index: Int) -> Unit = {}, + backgroundContent: (@Composable (size: DpSize) -> Unit)? = { + WheelPickerDefaults.Background(size = it) + }, +) { + WheelPicker( + modifier = modifier, + startIndex = startIndex, + count = remember(texts) { texts.size }, + size = size, + onSelectionChanged = onSelectionChanged, + backgroundContent = backgroundContent, + ) { + WheelPickerDefaults.Item(text = texts[it]) + } +} + +@Composable +fun WheelDatePicker( + modifier: Modifier = Modifier, + startDate: LocalDate = LocalDate.now(), + minDate: LocalDate? = null, + maxDate: LocalDate? = null, + size: DpSize = DpSize(256.dp, 128.dp), + backgroundContent: (@Composable (size: DpSize) -> Unit)? = { + WheelPickerDefaults.Background(size = it) + }, + onSelectionChanged: (date: LocalDate) -> Unit = {}, +) { + var internalSelection by remember { mutableStateOf(startDate) } + val internalOnSelectionChange: (LocalDate) -> Unit = { + internalSelection = it + onSelectionChanged(internalSelection) + } + + Box(modifier = modifier, contentAlignment = Alignment.Center) { + backgroundContent?.invoke(size) + Row { + val singularPickerSize = DpSize( + width = size.width / 3, + height = size.height, + ) + + // Day + val dayOfMonths = remember(internalSelection, minDate, maxDate) { + if (minDate == null && maxDate == null) { + 1..internalSelection.lengthOfMonth() + } else { + val minDay = if (minDate?.month == internalSelection.month && + minDate?.year == internalSelection.year + ) { + minDate.dayOfMonth + } else { + 1 + } + val maxDay = if (maxDate?.month == internalSelection.month && + maxDate?.year == internalSelection.year + ) { + maxDate.dayOfMonth + } else { + 31 + } + minDay..maxDay.coerceAtMost(internalSelection.lengthOfMonth()) + }.toList() + } + WheelTextPicker( + size = singularPickerSize, + texts = dayOfMonths.map { it.toString() }, + backgroundContent = null, + startIndex = dayOfMonths.indexOfFirst { it == startDate.dayOfMonth }.coerceAtLeast(0), + onSelectionChanged = { index -> + val newDayOfMonth = dayOfMonths[index] + internalOnSelectionChange(internalSelection.withDayOfMonth(newDayOfMonth)) + }, + ) + + // Month + val months = remember(internalSelection, minDate, maxDate) { + val monthRange = if (minDate == null && maxDate == null) { + 1..12 + } else { + val minMonth = if (minDate?.year == internalSelection.year) { + minDate.monthValue + } else { + 1 + } + val maxMonth = if (maxDate?.year == internalSelection.year) { + maxDate.monthValue + } else { + 12 + } + minMonth..maxMonth + } + val dateFormatSymbols = DateFormatSymbols() + monthRange.map { it to dateFormatSymbols.months[it - 1] } + } + WheelTextPicker( + size = singularPickerSize, + texts = months.map { it.second }, + backgroundContent = null, + startIndex = months.indexOfFirst { it.first == startDate.monthValue }.coerceAtLeast(0), + onSelectionChanged = { index -> + val newMonth = months[index].first + internalOnSelectionChange(internalSelection.withMonth(newMonth)) + }, + ) + + // Year + val years = remember(minDate, maxDate) { + val minYear = minDate?.year?.coerceAtLeast(1900) ?: 1900 + val maxYear = maxDate?.year?.coerceAtMost(2100) ?: 2100 + val yearRange = minYear..maxYear + yearRange.toList() + } + WheelTextPicker( + size = singularPickerSize, + texts = years.map { it.toString() }, + backgroundContent = null, + startIndex = years.indexOfFirst { it == startDate.year }.coerceAtLeast(0), + onSelectionChanged = { index -> + val newYear = years[index] + internalOnSelectionChange(internalSelection.withYear(newYear)) + }, + ) + } + } +} + +private fun LazyListState.snapOffsetForItem(itemInfo: LazyListItemInfo): Int { + val startScrollOffset = 0 + val endScrollOffset = layoutInfo.let { it.viewportEndOffset - it.afterContentPadding } + return startScrollOffset + (endScrollOffset - startScrollOffset - itemInfo.size) / 2 +} + +private fun LazyListState.distanceToSnapForIndex(index: Int): Int { + val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } + if (itemInfo != null) { + return itemInfo.offset - snapOffsetForItem(itemInfo) + } + return 0 +} + +private fun calculateAnimatedAlpha( + lazyListState: LazyListState, + index: Int, +): Float { + val distanceToIndexSnap = lazyListState.distanceToSnapForIndex(index).absoluteValue + val viewPortHeight = lazyListState.layoutInfo.viewportSize.height.toFloat() + val singleViewPortHeight = viewPortHeight / RowCount + return if (distanceToIndexSnap in 0..singleViewPortHeight.toInt()) { + 1.2f - (distanceToIndexSnap / singleViewPortHeight) + } else { + 0.2f + } +} + +private fun calculateSnappedItemIndex(lazyListState: LazyListState): Int { + return lazyListState.layoutInfo.visibleItemsInfo + .maxBy { calculateAnimatedAlpha(lazyListState, it.index) } + .index +} + +object WheelPickerDefaults { + @Composable + fun Background(size: DpSize) { + androidx.compose.material3.Surface( + modifier = Modifier + .size(size.width, size.height / RowCount), + shape = RoundedCornerShape(MaterialTheme.padding.medium), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary), + content = {}, + ) + } + + @Composable + fun Item(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + ) + } +} + +private const val RowCount = 3 diff --git a/app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogSelector.kt b/app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogSelector.kt index 191a1313d..f87672521 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogSelector.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/TrackInfoDialogSelector.kt @@ -29,11 +29,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.commandiron.wheel_picker_compose.WheelDatePicker -import com.commandiron.wheel_picker_compose.WheelTextPicker import eu.kanade.presentation.components.AlertDialogContent import eu.kanade.presentation.components.Divider import eu.kanade.presentation.components.ScrollbarLazyColumn +import eu.kanade.presentation.components.WheelDatePicker +import eu.kanade.presentation.components.WheelTextPicker import eu.kanade.presentation.util.isScrolledToEnd import eu.kanade.presentation.util.isScrolledToStart import eu.kanade.presentation.util.minimumTouchTargetSize @@ -103,12 +103,9 @@ fun TrackChapterSelector( content = { WheelTextPicker( modifier = Modifier.align(Alignment.Center), - texts = range.map { "$it" }, - onScrollFinished = { - onSelectionChange(it) - null - }, startIndex = selection, + texts = range.map { "$it" }, + onSelectionChanged = { onSelectionChange(it) }, ) }, onConfirm = onConfirm, @@ -129,12 +126,9 @@ fun TrackScoreSelector( content = { WheelTextPicker( modifier = Modifier.align(Alignment.Center), - texts = selections, - onScrollFinished = { - onSelectionChange(selections[it]) - null - }, startIndex = selections.indexOf(selection).coerceAtLeast(0), + texts = selections, + onSelectionChanged = { onSelectionChange(selections[it]) }, ) }, onConfirm = onConfirm, @@ -145,6 +139,8 @@ fun TrackScoreSelector( @Composable fun TrackDateSelector( title: String, + minDate: LocalDate?, + maxDate: LocalDate?, selection: LocalDate, onSelectionChange: (LocalDate) -> Unit, onConfirm: () -> Unit, @@ -170,7 +166,9 @@ fun TrackDateSelector( ) WheelDatePicker( startDate = selection, - onScrollFinished = { + minDate = minDate, + maxDate = maxDate, + onSelectionChanged = { internalSelection = it onSelectionChange(it) }, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt index 20b6c05f8..24be5033c 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt @@ -1,15 +1,12 @@ package eu.kanade.presentation.more.settings.screen import androidx.annotation.StringRes -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -23,7 +20,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -35,10 +31,11 @@ import androidx.core.content.ContextCompat import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.currentOrThrow -import com.commandiron.wheel_picker_compose.WheelPicker import eu.kanade.domain.category.interactor.ResetCategoryFlags import eu.kanade.domain.library.service.LibraryPreferences import eu.kanade.presentation.category.visualName +import eu.kanade.presentation.components.WheelPicker +import eu.kanade.presentation.components.WheelPickerDefaults import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.widget.TriStateListDialog import eu.kanade.presentation.util.collectAsState @@ -337,12 +334,7 @@ object SettingsLibraryScreen : SearchableSettings { modifier = modifier, contentAlignment = Alignment.Center, ) { - Surface( - modifier = Modifier.size(maxWidth, maxHeight / 3), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary), - ) {} + WheelPickerDefaults.Background(size = DpSize(maxWidth, maxHeight)) val size = DpSize(width = maxWidth / 2, height = 128.dp) Row { @@ -350,48 +342,24 @@ object SettingsLibraryScreen : SearchableSettings { size = size, count = 11, startIndex = portraitValue, - onScrollFinished = { - onPortraitChange(it) - null - }, - ) { index, snappedIndex -> - ColumnPickerLabel(index = index, snappedIndex = snappedIndex) + onSelectionChanged = onPortraitChange, + backgroundContent = null, + ) { index -> + WheelPickerDefaults.Item(text = getColumnValue(value = index)) } WheelPicker( size = size, count = 11, startIndex = landscapeValue, - onScrollFinished = { - onLandscapeChange(it) - null - }, - ) { index, snappedIndex -> - ColumnPickerLabel(index = index, snappedIndex = snappedIndex) + onSelectionChanged = onLandscapeChange, + backgroundContent = null, + ) { index -> + WheelPickerDefaults.Item(text = getColumnValue(value = index)) } } } } - @Composable - private fun ColumnPickerLabel( - index: Int, - snappedIndex: Int, - ) { - Text( - modifier = Modifier.alpha( - when (snappedIndex) { - index + 1 -> 0.2f - index -> 1f - index - 1 -> 0.2f - else -> 0.2f - }, - ), - text = getColumnValue(index), - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - ) - } - @Composable @ReadOnlyComposable private fun getColumnValue(value: Int): String { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt index efde7fa62..a81003d7c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackInfoDialog.kt @@ -445,6 +445,19 @@ private data class TrackDateSelectorScreen( } else { stringResource(R.string.track_finished_reading_date) }, + minDate = if (!start && track.started_reading_date > 0) { + // Disallow end date to be set earlier than start date + Instant.ofEpochMilli(track.started_reading_date).atZone(ZoneId.systemDefault()).toLocalDate() + } else { + null + }, + maxDate = if (start && track.finished_reading_date > 0) { + // Disallow start date to be set later than finish date + Instant.ofEpochMilli(track.finished_reading_date).atZone(ZoneId.systemDefault()).toLocalDate() + } else { + // Disallow future dates + LocalDate.now() + }, selection = state.selection, onSelectionChange = sm::setSelection, onConfirm = { sm.setDate(); navigator.pop() }, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b2e9d0234..acb84e2a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,7 +61,6 @@ photoview = "com.github.chrisbanes:PhotoView:2.3.0" directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0" insetter = "dev.chrisbanes.insetter:insetter:0.6.1" cascade = "me.saket.cascade:cascade-compose:2.0.0-rc01" -wheelpicker = "com.github.commandiron:WheelPickerCompose:1.0.11" materialmotion-core = "io.github.fornewid:material-motion-compose-core:0.10.4" logcat = "com.squareup.logcat:logcat:0.1"