diff --git a/api/anilist/src/main/graphql/fragments/User.graphql b/api/anilist/src/main/graphql/fragments/User.graphql index a25993a0b..e5d027725 100644 --- a/api/anilist/src/main/graphql/fragments/User.graphql +++ b/api/anilist/src/main/graphql/fragments/User.graphql @@ -34,4 +34,12 @@ fragment User on User { } } } + mediaListOptions { + animeList { + sectionOrder + } + mangaList { + sectionOrder + } + } } diff --git a/api/anilist/src/main/kotlin/com/imashnake/animite/api/anilist/AnilistUserRepository.kt b/api/anilist/src/main/kotlin/com/imashnake/animite/api/anilist/AnilistUserRepository.kt index 70772614b..705c26aaa 100644 --- a/api/anilist/src/main/kotlin/com/imashnake/animite/api/anilist/AnilistUserRepository.kt +++ b/api/anilist/src/main/kotlin/com/imashnake/animite/api/anilist/AnilistUserRepository.kt @@ -41,6 +41,7 @@ class AnilistUserRepository( type: MediaType?, useNetwork: Boolean, language: Media.Language = Media.Language.DEFAULT, + mediaListOrder: List ): Flow> { return apolloClient.query( UserMediaListQuery( @@ -55,6 +56,6 @@ class AnilistUserRepository( ) .toFlow() .filter { it.exception == null } - .asResult { User.MediaCollection(it, type, language) } + .asResult { User.MediaCollection(it, type, language, mediaListOrder) } } } diff --git a/api/anilist/src/main/kotlin/com/imashnake/animite/api/anilist/sanitize/profile/Viewer.kt b/api/anilist/src/main/kotlin/com/imashnake/animite/api/anilist/sanitize/profile/Viewer.kt index 1f9cbc623..5338f1478 100644 --- a/api/anilist/src/main/kotlin/com/imashnake/animite/api/anilist/sanitize/profile/Viewer.kt +++ b/api/anilist/src/main/kotlin/com/imashnake/animite/api/anilist/sanitize/profile/Viewer.kt @@ -11,6 +11,7 @@ import com.imashnake.animite.api.anilist.sanitize.profile.User.ListNames.Compani import com.imashnake.animite.api.anilist.type.MediaType import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlin.collections.indexOf import kotlin.time.Duration.Companion.minutes import kotlin.time.DurationUnit @@ -48,6 +49,13 @@ data class User( val genres: ImmutableList, // endregion + // region Anime + /** @see User.AnimeList.sectionOrder */ + val animeListOrder: List, + /** @see User.MangaList.sectionOrder */ + val mangaListOrder: List, + // endregion + // region Fave /** @see User.favourites */ val favourites: ImmutableList, @@ -102,10 +110,13 @@ data class User( internal constructor( query: UserMediaListQuery.Data, type: MediaType?, - language: Language + language: Language, + mediaListOrder: List ) : this( type = type?.name?.let { Type.valueOf(it) } ?: Type.UNKNOWN, - namedLists = query.mediaListCollection?.lists.orEmpty().mapNotNull { + namedLists = query.mediaListCollection?.lists.orEmpty().sortedBy { + mediaListOrder.indexOf(it?.name) + }.mapNotNull { NamedTrackingList(it ?: return@mapNotNull null, language) }.toImmutableList() ) @@ -182,6 +193,8 @@ data class User( it.mediaCount > totalCount/20 }.sortedByDescending { it.mediaCount } }.toImmutableList(), + animeListOrder = query.mediaListOptions?.animeList?.sectionOrder.orEmpty().filterNotNull(), + mangaListOrder = query.mediaListOptions?.mangaList?.sectionOrder.orEmpty().filterNotNull(), favourites = listOfNotNull( query.favourites?.anime?.let { FavouriteCollection.FavouriteList(it, language) }.takeIf { it?.list?.isNotEmpty() == true }, query.favourites?.manga?.let { FavouriteCollection.FavouriteList(it, language) }.takeIf { it?.list?.isNotEmpty() == true }, diff --git a/api/preferences/build.gradle.kts b/api/preferences/build.gradle.kts index f243f6538..3bea7f408 100644 --- a/api/preferences/build.gradle.kts +++ b/api/preferences/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.libraryMultiplatform) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.detekt) + alias(libs.plugins.kotlin.serialization) } kotlin { @@ -14,6 +15,7 @@ kotlin { sourceSets { commonMain.dependencies { implementation(libs.datastore) + implementation(libs.kotlinx.serialization.json) } } } diff --git a/api/preferences/src/commonMain/kotlin/com/imashnake/animite/api/preferences/PreferencesRepository.kt b/api/preferences/src/commonMain/kotlin/com/imashnake/animite/api/preferences/PreferencesRepository.kt index 46e050b7c..8c063bf5c 100644 --- a/api/preferences/src/commonMain/kotlin/com/imashnake/animite/api/preferences/PreferencesRepository.kt +++ b/api/preferences/src/commonMain/kotlin/com/imashnake/animite/api/preferences/PreferencesRepository.kt @@ -10,6 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import com.imashnake.animite.api.preferences.ext.getValue import com.imashnake.animite.api.preferences.ext.setValue import kotlinx.coroutines.flow.firstOrNull +import kotlinx.serialization.json.Json private const val DEFAULT_THEME_KEY = "DEVICE_THEME" private const val DEFAULT_DENSITY_KEY = "COMFY" @@ -87,6 +88,18 @@ class PreferencesRepository internal constructor( dataStore.setValue(viewerIdKey, viewerId.toString()) } + private val animeListOrderKey = stringPreferencesKey("anime_list_order") + val animeListOrder = dataStore.getValue(animeListOrderKey, null) + suspend fun setAnimeListOrder(animeListOrder: List?) { + dataStore.setValue(animeListOrderKey, Json.encodeToString(animeListOrder)) + } + + private val mangaListOrderKey = stringPreferencesKey("manga_list_order") + val mangaListOrder = dataStore.getValue(mangaListOrderKey, null) + suspend fun setMangaListOrder(mangaListOrder: List?) { + dataStore.setValue(mangaListOrderKey, Json.encodeToString(mangaListOrder)) + } + private val viewerAvatarKey = stringPreferencesKey("viewer_avatar") val viewerAvatar = dataStore.getValue(viewerAvatarKey, null) suspend fun setViewerAvatar(avatarUrl: String?) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2d57f4795..725bdbfee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -148,6 +148,7 @@ kotlinx-collectionsImmutable = { group = "org.jetbrains.kotlinx", name = "kotlin kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "serialization" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxTime" } datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "dagger" } diff --git a/profile/build.gradle.kts b/profile/build.gradle.kts index 38dfa4748..7dbd99a0f 100644 --- a/profile/build.gradle.kts +++ b/profile/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.serialization.json) // Hilt implementation(libs.hilt.android) diff --git a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt index 1c4b46b3a..f49db61b5 100644 --- a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt @@ -469,6 +469,7 @@ private fun UserTabs( val onBackground = MaterialTheme.colorScheme.onBackground val horizontalContentPadding = contentPadding.horizontalOnly + // TODO: Store these in prefs. val animeListVisibility = remember(animeCollection?.namedLists?.size) { mutableStateMapOf( *List(animeCollection?.namedLists?.size ?: 0) { it to true }.toTypedArray() @@ -568,7 +569,6 @@ private fun UserTabs( ) + contentPadding.copy(top = 0.dp) val mediaTabContentPadding = PaddingValues( - top = LocalPaddings.current.small, start = LocalPaddings.current.medium, end = LocalPaddings.current.medium, bottom = LocalPaddings.current.medium diff --git a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileViewModel.kt b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileViewModel.kt index 1ebe4de9c..1be8d39f6 100644 --- a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileViewModel.kt +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileViewModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -51,7 +52,11 @@ class ProfileViewModel @Inject constructor( ).flatMapLatest { userRepository.fetchViewer(useNetwork, Media.Language.valueOf(it.second)) }.asResource().onEach { - it.data?.let { user -> preferencesRepository.setViewerId(user.id) } + it.data?.let { user -> + preferencesRepository.setViewerId(user.id) + preferencesRepository.setAnimeListOrder(user.animeListOrder) + preferencesRepository.setMangaListOrder(user.mangaListOrder) + } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(1000), @@ -60,15 +65,22 @@ class ProfileViewModel @Inject constructor( val viewerAnimeLists = combine( flow = refreshTrigger.onStart { emit(Unit) }, - flow2 = preferencesRepository.viewerId.filterNotNull(), - flow3 = preferencesRepository.language.filterNotNull(), + flow2 = combine( + preferencesRepository.viewerId.filterNotNull(), + preferencesRepository.language.filterNotNull(), + ::Pair + ), + flow3 = preferencesRepository.animeListOrder.filterNotNull().map { + Json.decodeFromString>(it) + }, transform = ::Triple, ).flatMapLatest { userRepository.fetchUserMediaList( - id = it.second.toIntOrNull(), + id = it.second.first.toIntOrNull(), type = MediaType.ANIME, useNetwork = useNetwork, - language = Media.Language.valueOf(it.third) + language = Media.Language.valueOf(it.second.second), + mediaListOrder = it.third ) }.asResource().stateIn( scope = viewModelScope, @@ -78,15 +90,22 @@ class ProfileViewModel @Inject constructor( val viewerMangaLists = combine( flow = refreshTrigger.onStart { emit(Unit) }, - flow2 = preferencesRepository.viewerId.filterNotNull(), - flow3 = preferencesRepository.language.filterNotNull(), + flow2 = combine( + preferencesRepository.viewerId.filterNotNull(), + preferencesRepository.language.filterNotNull(), + ::Pair + ), + flow3 = preferencesRepository.mangaListOrder.filterNotNull().map { + Json.decodeFromString>(it) + }, transform = ::Triple, ).flatMapLatest { userRepository.fetchUserMediaList( - id = it.second.toIntOrNull(), + id = it.second.first.toIntOrNull(), type = MediaType.MANGA, useNetwork = useNetwork, - language = Media.Language.valueOf(it.third) + language = Media.Language.valueOf(it.second.second), + mediaListOrder = it.third ) }.asResource().stateIn( scope = viewModelScope, diff --git a/profile/src/main/kotlin/com/imashnake/animite/profile/tabs/Favourites.kt b/profile/src/main/kotlin/com/imashnake/animite/profile/tabs/Favourites.kt index 7b2c2954a..b9b55ceaf 100644 --- a/profile/src/main/kotlin/com/imashnake/animite/profile/tabs/Favourites.kt +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/tabs/Favourites.kt @@ -138,6 +138,8 @@ private fun UserFavouriteLists( } } is Media.Credit -> { + // TODO: Make character sheet top level (like quick search) or have a + // dedicated page. CharacterCard( image = item.image, tag = null, diff --git a/profile/src/main/kotlin/com/imashnake/animite/profile/ui/MediaTrackingList.kt b/profile/src/main/kotlin/com/imashnake/animite/profile/ui/MediaTrackingList.kt index 71ae9612f..81018d0e9 100644 --- a/profile/src/main/kotlin/com/imashnake/animite/profile/ui/MediaTrackingList.kt +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/ui/MediaTrackingList.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType.Companion.ToggleOff import androidx.compose.ui.hapticfeedback.HapticFeedbackType.Companion.ToggleOn import androidx.compose.ui.platform.LocalHapticFeedback @@ -53,6 +54,9 @@ import com.imashnake.animite.media.ext.res import com.imashnake.animite.profile.R import com.imashnake.animite.profile.dev.res import kotlinx.collections.immutable.ImmutableList +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set @Composable fun MediaTrackingLists( @@ -64,13 +68,17 @@ fun MediaTrackingLists( state: LazyListState = rememberLazyListState(), contentPadding: PaddingValues = PaddingValues(), ) { - val haptic = LocalHapticFeedback.current LazyColumn( state = state, modifier = modifier, contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(LocalPaddings.current.small) ) { + item { + ListOptions( + expandAll = { listVisibility.forEach { (index, _) -> listVisibility[index] = true } }, + collapseAll = { listVisibility.forEach { (index, _) -> listVisibility[index] = false } } + ) + } namedLists.fastForEachIndexed { index, namedList -> stickyHeader(key = namedList.name) { namedList.name?.let { @@ -81,101 +89,183 @@ fun MediaTrackingLists( .size(LocalPaddings.current.small) .background(MaterialTheme.colorScheme.background) ) - Box( + HeaderPill( + name = it, + size = namedList.list.size, + index = index, + listVisibility = listVisibility, + ) + } + } + } + if (listVisibility[index] ?: true) { + items(namedList.list.size, key = { index.toString() + namedList.list[it].id }) { + Column(Modifier.animateItem()) { + Spacer( modifier = Modifier - .height(dimensionResource(R.dimen.tracking_list_header_height)) .fillMaxWidth() - .clip(CircleShape) - .clickable { - if (listVisibility[index] == true) { - haptic.performHapticFeedback(ToggleOff) - } else { - haptic.performHapticFeedback(ToggleOn) - } - listVisibility[index]?.let { visibility -> - listVisibility[index] = !visibility - } - } - .background( - MaterialTheme.colorScheme.surfaceContainerHigh.copy( - alpha = 0.95f + .size(LocalPaddings.current.small) + .background(MaterialTheme.colorScheme.background) + ) + MediaTrackingItem( + item = namedList.list[it], + onClick = { id, title -> + onNavigateToMediaItem( + MediaPage( + id = id, + source = "${namedList.name}" + type.type, + mediaType = type.name, + title = title ) ) - ) { - val iconPadding = - (dimensionResource(R.dimen.tracking_list_header_height) - - dimensionResource(R.dimen.tracking_list_header_icon_size)) / 2 - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.small), - modifier = Modifier - .align(Alignment.CenterStart) - .padding(iconPadding) - ) { - Icon( - imageVector = ImageVector.vectorResource(it.sanitize().res), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(dimensionResource(R.dimen.tracking_list_header_icon_size)) - ) - Text( - text = it, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium.copy(baselineShift = null), - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.small), - modifier = Modifier - .padding(end = iconPadding) - .align(Alignment.CenterEnd) - ) { - Text( - text = namedList.list.size.toString(), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - style = MaterialTheme.typography.bodyMedium.copy(baselineShift = null), + }, + modifier = Modifier + .padding(horizontal = dimensionResource(R.dimen.tracking_list_header_height) / 2) + .height(dimensionResource(R.dimen.tracking_list_item_height)) + .fillMaxWidth() + .clip( + RoundedCornerShape( + topStart = 18.dp, + bottomStart = 18.dp, + topEnd = if (it == 0) 18.dp else LocalPaddings.current.small, + bottomEnd = if (it == namedList.list.lastIndex) { + 18.dp + } else LocalPaddings.current.small, + ) ) - - DropDownIcon(isDroppedDown = listVisibility[index] ?: true) - } - } + .background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.025f)) + ) } } } - if (listVisibility[index] ?: true) { - items(namedList.list.size, key = { namedList.list[it].id }) { - MediaTrackingItem( - item = namedList.list[it], - onClick = { id, title -> - onNavigateToMediaItem( - MediaPage( - id = id, - source = "${namedList.name}" + type.type, - mediaType = type.name, - title = title - ) - ) - }, - modifier = Modifier - .padding(horizontal = dimensionResource(R.dimen.tracking_list_header_height) / 2) - .animateItem() - .height(dimensionResource(R.dimen.tracking_list_item_height)) - .fillMaxWidth() - .clip( - RoundedCornerShape( - topStart = 18.dp, - bottomStart = 18.dp, - topEnd = if (it == 0) 18.dp else LocalPaddings.current.small, - bottomEnd = if (it == namedList.list.lastIndex) { - 18.dp - } else LocalPaddings.current.small, - ) - ) - .background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.025f)) - ) + } + } +} + +@Composable +private fun ListOptions( + expandAll: () -> Unit, + collapseAll: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.small), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .padding(top = LocalPaddings.current.small) + .padding(vertical = LocalPaddings.current.small) + .fillMaxWidth() + ) { + ListOption( + icon = ImageVector.vectorResource(R.drawable.expand_all), + text = stringResource(R.string.expand_all), + onClick = expandAll + ) + ListOption( + icon = ImageVector.vectorResource(R.drawable.collapse_all), + text = stringResource(R.string.collapse_all), + onClick = collapseAll + ) + } +} + +@Composable +private fun ListOption( + icon: ImageVector, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val haptic = LocalHapticFeedback.current + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.tiny), + modifier = modifier + .clip(CircleShape) + .clickable { haptic.performHapticFeedback(HapticFeedbackType.ContextClick); onClick() } + .padding(LocalPaddings.current.tiny) + .padding(end = LocalPaddings.current.tiny) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(dimensionResource(R.dimen.list_options_icon_size)) + ) + Text( + text = text, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall.copy(baselineShift = null), + ) + } +} + +@Composable +private fun HeaderPill( + name: String, + size: Int, + index: Int, + listVisibility: SnapshotStateMap, + modifier: Modifier = Modifier +) { + val haptic = LocalHapticFeedback.current + Box( + modifier = modifier + .height(dimensionResource(R.dimen.tracking_list_header_height)) + .fillMaxWidth() + .clip(CircleShape) + .clickable { + if (listVisibility[index] == true) { + haptic.performHapticFeedback(ToggleOff) + } else { + haptic.performHapticFeedback(ToggleOn) + } + listVisibility[index]?.let { visibility -> + listVisibility[index] = !visibility } } + .background( + MaterialTheme.colorScheme.surfaceContainerHigh.copy( + alpha = 0.95f + ) + ) + ) { + val iconPadding = + (dimensionResource(R.dimen.tracking_list_header_height) + - dimensionResource(R.dimen.tracking_list_header_icon_size)) / 2 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.small), + modifier = Modifier + .align(Alignment.CenterStart) + .padding(iconPadding) + ) { + Icon( + imageVector = ImageVector.vectorResource(name.sanitize().res), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(dimensionResource(R.dimen.tracking_list_header_icon_size)) + ) + Text( + text = name, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium.copy(baselineShift = null), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.small), + modifier = Modifier + .padding(end = iconPadding) + .align(Alignment.CenterEnd) + ) { + Text( + text = size.toString(), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + style = MaterialTheme.typography.bodyMedium.copy(baselineShift = null), + ) + + DropDownIcon(isDroppedDown = listVisibility[index] ?: true) } } } diff --git a/profile/src/main/res/drawable/collapse_all.xml b/profile/src/main/res/drawable/collapse_all.xml new file mode 100644 index 000000000..38d67f20e --- /dev/null +++ b/profile/src/main/res/drawable/collapse_all.xml @@ -0,0 +1,24 @@ + + + + diff --git a/profile/src/main/res/drawable/expand_all.xml b/profile/src/main/res/drawable/expand_all.xml new file mode 100644 index 000000000..11c4efdc3 --- /dev/null +++ b/profile/src/main/res/drawable/expand_all.xml @@ -0,0 +1,24 @@ + + + + diff --git a/profile/src/main/res/values/dimens.xml b/profile/src/main/res/values/dimens.xml index d911532ba..9876e8e8b 100644 --- a/profile/src/main/res/values/dimens.xml +++ b/profile/src/main/res/values/dimens.xml @@ -9,4 +9,6 @@ 70dp 20dp 80dp + + 12dp diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 3c41c0af8..34defc02f 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -21,6 +21,9 @@ Genres + Expand All + Collapse All + %1$d ep %1$d eps