diff --git a/api/anilist/src/main/graphql/mutations/UpdateUser.graphql b/api/anilist/src/main/graphql/mutations/UpdateUser.graphql index e3307eae..5aedbd48 100644 --- a/api/anilist/src/main/graphql/mutations/UpdateUser.graphql +++ b/api/anilist/src/main/graphql/mutations/UpdateUser.graphql @@ -1,7 +1,23 @@ -mutation UpdateUser($profileColor: String) { - UpdateUser(profileColor: $profileColor) { +mutation UpdateUser( + $profileColor: String, + $animeListOptions: MediaListOptionsInput, + $mangaListOptions: MediaListOptionsInput, +) { + UpdateUser( + profileColor: $profileColor, + animeListOptions: $animeListOptions + mangaListOptions: $mangaListOptions + ) { options { profileColor } + 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 bc6eee42..26871280 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 @@ -6,6 +6,7 @@ import com.apollographql.cache.normalized.FetchPolicy import com.apollographql.cache.normalized.fetchPolicy import com.imashnake.animite.api.anilist.sanitize.media.Media import com.imashnake.animite.api.anilist.sanitize.profile.User +import com.imashnake.animite.api.anilist.type.MediaListOptionsInput import com.imashnake.animite.api.anilist.type.MediaType import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter @@ -59,11 +60,29 @@ class AnilistUserRepository( .asResult { User.MediaCollection(it, type, language, mediaListOrder) } } - fun updateUser(profileColor: String?): Flow> { + fun updateUser( + profileColor: String? = null, + animeSectionOrder: List? = null, + mangaSectionOrder: List? = null, + ): Flow> { return apolloClient - .mutation(UpdateUserMutation(Optional.presentIfNotNull(profileColor))) + .mutation( + UpdateUserMutation( + profileColor = Optional.presentIfNotNull(profileColor), + animeListOptions = Optional.presentIfNotNull( + MediaListOptionsInput( + sectionOrder = Optional.presentIfNotNull(animeSectionOrder) + ) + ), + mangaListOptions = Optional.presentIfNotNull( + MediaListOptionsInput( + sectionOrder = Optional.presentIfNotNull(mangaSectionOrder) + ) + ) + ) + ) .fetchPolicy(FetchPolicy.NetworkOnly) .toFlow() - .asResult { it.UpdateUser?.options?.profileColor } + .asResult { it.UpdateUser } } } diff --git a/profile/build.gradle.kts b/profile/build.gradle.kts index 7dbd99a0..ad1569d8 100644 --- a/profile/build.gradle.kts +++ b/profile/build.gradle.kts @@ -82,6 +82,8 @@ dependencies { implementation(libs.hilt.navigationCompose) ksp(libs.hilt.android.compiler) + implementation(libs.reorderable) + testImplementation(libs.test.junit) androidTestImplementation(libs.androidx.test.junit) 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 0bc63ac2..1a6c462a 100644 --- a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt @@ -260,11 +260,11 @@ fun ProfileScreen( user = this@run, animeCollection = viewerAnimeLists.data, mangaCollection = viewerMangaLists.data, + updateAnimeListsOrder = viewModel::updateAnimeLists, + updateMangaListsOrder = viewModel::updateMangaLists, onNavigateToMediaItem = onNavigateToMediaItem, showUserDescription = showUserDescription, - onUserDescriptionClick = { - showUserDescriptionSheet = true - }, + onUserDescriptionClick = { showUserDescriptionSheet = true }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, contentPadding = navigationComponentPaddingValues + insetPaddingValues, @@ -482,6 +482,8 @@ private fun UserTabs( user: User, animeCollection: User.MediaCollection?, mangaCollection: User.MediaCollection?, + updateAnimeListsOrder: (List) -> Unit, + updateMangaListsOrder: (List) -> Unit, onNavigateToMediaItem: (MediaPage) -> Unit, showUserDescription: Boolean, onUserDescriptionClick: () -> Unit, @@ -612,12 +614,14 @@ private fun UserTabs( ProfileTab.ANIME -> MediaTab( mediaCollection = animeCollection, listVisibility = animeListVisibility, + updateMediaListsOrder = updateAnimeListsOrder, onNavigateToMediaItem = onNavigateToMediaItem, contentPadding = mediaTabContentPadding, ) ProfileTab.MANGA -> MediaTab( mediaCollection = mangaCollection, listVisibility = mangaListVisibility, + updateMediaListsOrder = updateMangaListsOrder, onNavigateToMediaItem = onNavigateToMediaItem, contentPadding = mediaTabContentPadding, ) 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 ca8d3b14..40f88378 100644 --- a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileViewModel.kt +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileViewModel.kt @@ -38,6 +38,8 @@ class ProfileViewModel @Inject constructor( private val navArgs = savedStateHandle.toRoute() private val refreshTrigger = MutableSharedFlow() + private val animeListsRefreshTrigger = MutableSharedFlow() + private val mangaListsRefreshTrigger = MutableSharedFlow() var useNetwork = false @@ -71,7 +73,7 @@ class ProfileViewModel @Inject constructor( ) val viewerAnimeLists = combine( - flow = refreshTrigger.onStart { emit(Unit) }, + flow = animeListsRefreshTrigger.onStart { emit(Unit) }, flow2 = combine( preferencesRepository.viewerId.filterNotNull(), preferencesRepository.language.filterNotNull(), @@ -96,7 +98,7 @@ class ProfileViewModel @Inject constructor( ) val viewerMangaLists = combine( - flow = refreshTrigger.onStart { emit(Unit) }, + flow = mangaListsRefreshTrigger.onStart { emit(Unit) }, flow2 = combine( preferencesRepository.viewerId.filterNotNull(), preferencesRepository.language.filterNotNull(), @@ -120,23 +122,51 @@ class ProfileViewModel @Inject constructor( initialValue = Resource.loading(), ) - fun logOut() = viewModelScope.launch(Dispatchers.IO) { - with(preferencesRepository) { - setAccessToken(null) - setViewerId(null) - setViewerAvatar(null) + fun updateAnimeLists(sectionOrder: List) = viewModelScope.launch(Dispatchers.IO) { + userRepository.updateUser(animeSectionOrder = sectionOrder).collect { + preferencesRepository.setAnimeListOrder(it.getOrNull()?.mediaListOptions?.animeList?.sectionOrder?.filterNotNull()) } + refreshAnimeLists() + } + + fun updateMangaLists(sectionOrder: List) = viewModelScope.launch(Dispatchers.IO) { + userRepository.updateUser(mangaSectionOrder = sectionOrder).collect { + preferencesRepository.setMangaListOrder(it.getOrNull()?.mediaListOptions?.mangaList?.sectionOrder?.filterNotNull()) + } + refreshMangaLists() + } + + private fun refreshAnimeLists() = viewModelScope.launch(Dispatchers.IO) { + useNetwork = true + animeListsRefreshTrigger.emit(Unit) + useNetwork = false } - fun refresh(setIsRefreshing: (Boolean) -> Unit) = viewModelScope.launch { + private fun refreshMangaLists() = viewModelScope.launch(Dispatchers.IO) { + useNetwork = true + mangaListsRefreshTrigger.emit(Unit) + useNetwork = false + } + + fun refresh(setIsRefreshing: (Boolean) -> Unit) = viewModelScope.launch(Dispatchers.IO) { setIsRefreshing(true) useNetwork = true refreshTrigger.emit(Unit) + animeListsRefreshTrigger.emit(Unit) + mangaListsRefreshTrigger.emit(Unit) delay(1500L) useNetwork = false setIsRefreshing(false) } + fun logOut() = viewModelScope.launch(Dispatchers.IO) { + with(preferencesRepository) { + setAccessToken(null) + setViewerId(null) + setViewerAvatar(null) + } + } + val viewerAvatar = preferencesRepository.viewerAvatar fun saveViewerAvatar(avatarUrl: String?) = viewModelScope.launch(Dispatchers.IO) { preferencesRepository.setViewerAvatar(avatarUrl) diff --git a/profile/src/main/kotlin/com/imashnake/animite/profile/tabs/Media.kt b/profile/src/main/kotlin/com/imashnake/animite/profile/tabs/Media.kt index 4ab4b9d2..cbb73196 100644 --- a/profile/src/main/kotlin/com/imashnake/animite/profile/tabs/Media.kt +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/tabs/Media.kt @@ -25,6 +25,7 @@ import kotlinx.collections.immutable.ImmutableList fun MediaTab( mediaCollection: User.MediaCollection?, listVisibility: SnapshotStateMap, + updateMediaListsOrder: (List) -> Unit, onNavigateToMediaItem: (MediaPage) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), @@ -48,6 +49,7 @@ fun MediaTab( type = mediaCollection.type, lists = mediaCollection.namedLists, listVisibility = listVisibility, + updateMediaListsOrder = updateMediaListsOrder, onNavigateToMediaItem = onNavigateToMediaItem, modifier = modifier, contentPadding = contentPadding, @@ -61,6 +63,7 @@ private fun UserMediaLists( type: Media.Small.Type, lists: ImmutableList, listVisibility: SnapshotStateMap, + updateMediaListsOrder: (List) -> Unit, onNavigateToMediaItem: (MediaPage) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), @@ -69,6 +72,7 @@ private fun UserMediaLists( type = type, namedLists = lists, listVisibility = listVisibility, + updateMediaListsOrder = updateMediaListsOrder, onNavigateToMediaItem = onNavigateToMediaItem, contentPadding = contentPadding, modifier = modifier.fillMaxSize() 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 81018d0e..d33f5d78 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 @@ -1,5 +1,7 @@ package com.imashnake.animite.profile.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -11,12 +13,15 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialShapes @@ -24,7 +29,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.toShape 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.snapshots.SnapshotStateMap +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,6 +52,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed +import androidx.compose.ui.util.fastMapNotNull import com.imashnake.animite.api.anilist.sanitize.media.Media import com.imashnake.animite.api.anilist.sanitize.profile.User import com.imashnake.animite.api.anilist.sanitize.profile.User.ListNames.Companion.sanitize @@ -54,20 +65,30 @@ 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 +import sh.calvin.reorderable.ReorderableCollectionItemScope +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState @Composable fun MediaTrackingLists( type: Media.Small.Type, namedLists: ImmutableList, listVisibility: SnapshotStateMap, + updateMediaListsOrder: (List) -> Unit, onNavigateToMediaItem: (MediaPage) -> Unit, modifier: Modifier = Modifier, state: LazyListState = rememberLazyListState(), contentPadding: PaddingValues = PaddingValues(), ) { + val namedLists = remember { namedLists.toMutableStateList() } + + val haptic = LocalHapticFeedback.current + var isReordering by remember { mutableStateOf(false) } + val reorderableLazyListState = rememberReorderableLazyListState(state) { from, to -> + namedLists.apply { add(to.index - 1, removeAt(from.index - 1)) } + haptic.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) + } + LazyColumn( state = state, modifier = modifier, @@ -75,26 +96,38 @@ fun MediaTrackingLists( ) { item { ListOptions( + isReordering = isReordering, expandAll = { listVisibility.forEach { (index, _) -> listVisibility[index] = true } }, - collapseAll = { listVisibility.forEach { (index, _) -> listVisibility[index] = false } } + collapseAll = { listVisibility.forEach { (index, _) -> listVisibility[index] = false } }, + setIsReordering = { isReordering = it }, + onDone = { updateMediaListsOrder(namedLists.fastMapNotNull { it.name }.toList()) }, + modifier = Modifier.fillMaxWidth() ) } namedLists.fastForEachIndexed { index, namedList -> stickyHeader(key = namedList.name) { namedList.name?.let { - Column(Modifier.animateItem()) { - Spacer( - modifier = Modifier - .fillMaxWidth() - .size(LocalPaddings.current.small) - .background(MaterialTheme.colorScheme.background) - ) - HeaderPill( - name = it, - size = namedList.list.size, - index = index, - listVisibility = listVisibility, - ) + ReorderableItem(reorderableLazyListState, key = it) { _ -> + Column(Modifier.animateItem()) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .size(LocalPaddings.current.small) + .background( + if (isReordering) { + Color.Transparent + } else MaterialTheme.colorScheme.background + ) + ) + HeaderPill( + name = it, + size = namedList.list.size, + index = index, + listVisibility = listVisibility, + isReordering = isReordering, + reorderScope = this@ReorderableItem + ) + } } } } @@ -144,28 +177,57 @@ fun MediaTrackingLists( @Composable private fun ListOptions( + isReordering: Boolean, expandAll: () -> Unit, collapseAll: () -> Unit, + setIsReordering: (Boolean) -> Unit, + onDone: () -> Unit, modifier: Modifier = Modifier ) { Row( - horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.small), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, 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 - ) + Row(horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.small)) { + val alpha by animateFloatAsState(if (isReordering) 0.3f else 1f) + ListOption( + icon = ImageVector.vectorResource(R.drawable.expand_all), + text = stringResource(R.string.expand_all), + onClick = { if (!isReordering) expandAll() }, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + ) + ListOption( + text = stringResource(R.string.collapse_all), + onClick = { if (!isReordering) collapseAll() }, + icon = ImageVector.vectorResource(R.drawable.collapse_all), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha) + ) + } + + AnimatedContent(isReordering) { + if (it) { + ListOption( + icon = Icons.Rounded.Check, + text = stringResource(R.string.done), + onClick = { + onDone() + setIsReordering(false) + }, + background = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(end = 10.dp) + ) + } else { + ListOption( + icon = ImageVector.vectorResource(R.drawable.reorder), + text = stringResource(R.string.reorder), + onClick = { collapseAll(); setIsReordering(true) } + ) + } + } } } @@ -174,7 +236,9 @@ private fun ListOption( icon: ImageVector, text: String, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + background: Color = Color.Transparent, + contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant ) { val haptic = LocalHapticFeedback.current Row( @@ -182,6 +246,7 @@ private fun ListOption( horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.tiny), modifier = modifier .clip(CircleShape) + .background(background) .clickable { haptic.performHapticFeedback(HapticFeedbackType.ContextClick); onClick() } .padding(LocalPaddings.current.tiny) .padding(end = LocalPaddings.current.tiny) @@ -189,12 +254,12 @@ private fun ListOption( Icon( imageVector = icon, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = contentColor, modifier = Modifier.size(dimensionResource(R.dimen.list_options_icon_size)) ) Text( text = text, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = contentColor, style = MaterialTheme.typography.labelSmall.copy(baselineShift = null), ) } @@ -206,6 +271,8 @@ private fun HeaderPill( size: Int, index: Int, listVisibility: SnapshotStateMap, + isReordering: Boolean, + reorderScope: ReorderableCollectionItemScope, modifier: Modifier = Modifier ) { val haptic = LocalHapticFeedback.current @@ -215,13 +282,13 @@ private fun HeaderPill( .fillMaxWidth() .clip(CircleShape) .clickable { - if (listVisibility[index] == true) { - haptic.performHapticFeedback(ToggleOff) - } else { - haptic.performHapticFeedback(ToggleOn) - } - listVisibility[index]?.let { visibility -> - listVisibility[index] = !visibility + haptic.performHapticFeedback( + if (listVisibility[index] == true) ToggleOff else ToggleOn + ) + if (!isReordering) { + listVisibility[index]?.let { visibility -> + listVisibility[index] = !visibility + } } } .background( @@ -265,7 +332,19 @@ private fun HeaderPill( style = MaterialTheme.typography.bodyMedium.copy(baselineShift = null), ) - DropDownIcon(isDroppedDown = listVisibility[index] ?: true) + AnimatedContent(isReordering) { + if (!it) { + DropDownIcon(isDroppedDown = listVisibility[index] ?: true) + } else { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.drag_indicator), + contentDescription = null, + modifier = with(reorderScope) { + modifier.requiredSize(16.dp).draggableHandle() + } + ) + } + } } } } diff --git a/profile/src/main/res/drawable/drag_indicator.xml b/profile/src/main/res/drawable/drag_indicator.xml new file mode 100644 index 00000000..9826370e --- /dev/null +++ b/profile/src/main/res/drawable/drag_indicator.xml @@ -0,0 +1,24 @@ + + + + diff --git a/profile/src/main/res/drawable/reorder.xml b/profile/src/main/res/drawable/reorder.xml new file mode 100644 index 00000000..e22c591f --- /dev/null +++ b/profile/src/main/res/drawable/reorder.xml @@ -0,0 +1,24 @@ + + + + diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 34defc02..18150abc 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -23,6 +23,8 @@ Expand All Collapse All + Reorder + Done %1$d ep diff --git a/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsViewModel.kt b/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsViewModel.kt index 48632e40..3abb98c8 100644 --- a/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsViewModel.kt +++ b/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsViewModel.kt @@ -69,9 +69,9 @@ class SettingsViewModel @Inject constructor( val profileColor = preferencesRepository.profileColor .filterNotNull() .flatMapLatest { - userRepository.updateUser(it).onEach { profileColor -> - preferencesRepository.setProfileColor(profileColor.getOrNull()) - } + userRepository.updateUser(it).onEach { result -> + preferencesRepository.setProfileColor(result.getOrNull()?.options?.profileColor) + }.map { result -> result.map { updateUser -> updateUser?.options?.profileColor } } } .asResource() .stateIn(