diff --git a/api/anilist/src/main/graphql/mutations/UpdateUser.graphql b/api/anilist/src/main/graphql/mutations/UpdateUser.graphql new file mode 100644 index 00000000..e3307eae --- /dev/null +++ b/api/anilist/src/main/graphql/mutations/UpdateUser.graphql @@ -0,0 +1,7 @@ +mutation UpdateUser($profileColor: String) { + UpdateUser(profileColor: $profileColor) { + options { + profileColor + } + } +} diff --git a/api/anilist/src/main/graphql/MediaListQuery.graphql b/api/anilist/src/main/graphql/queries/MediaListQuery.graphql similarity index 100% rename from api/anilist/src/main/graphql/MediaListQuery.graphql rename to api/anilist/src/main/graphql/queries/MediaListQuery.graphql diff --git a/api/anilist/src/main/graphql/MediaMediumListQuery.graphql b/api/anilist/src/main/graphql/queries/MediaMediumListQuery.graphql similarity index 100% rename from api/anilist/src/main/graphql/MediaMediumListQuery.graphql rename to api/anilist/src/main/graphql/queries/MediaMediumListQuery.graphql diff --git a/api/anilist/src/main/graphql/MediaQuery.graphql b/api/anilist/src/main/graphql/queries/MediaQuery.graphql similarity index 100% rename from api/anilist/src/main/graphql/MediaQuery.graphql rename to api/anilist/src/main/graphql/queries/MediaQuery.graphql diff --git a/api/anilist/src/main/graphql/UserQuery.graphql b/api/anilist/src/main/graphql/queries/UserQuery.graphql similarity index 100% rename from api/anilist/src/main/graphql/UserQuery.graphql rename to api/anilist/src/main/graphql/queries/UserQuery.graphql 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 705c26aa..bc6eee42 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 @@ -58,4 +58,12 @@ class AnilistUserRepository( .filter { it.exception == null } .asResult { User.MediaCollection(it, type, language, mediaListOrder) } } + + fun updateUser(profileColor: String?): Flow> { + return apolloClient + .mutation(UpdateUserMutation(Optional.presentIfNotNull(profileColor))) + .fetchPolicy(FetchPolicy.NetworkOnly) + .toFlow() + .asResult { it.UpdateUser?.options?.profileColor } + } } 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 3918e63b..6d4a3b1d 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 @@ -8,7 +8,6 @@ import com.imashnake.animite.api.anilist.sanitize.media.Media import com.imashnake.animite.api.anilist.sanitize.media.Media.Language import com.imashnake.animite.api.anilist.sanitize.media.Media.Small.Type import com.imashnake.animite.api.anilist.sanitize.profile.User.ListNames.Companion.sanitize -import com.imashnake.animite.api.anilist.sanitize.profile.User.ProfileColors.Companion.toHexString import com.imashnake.animite.api.anilist.type.MediaType import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -172,7 +171,8 @@ data class User( description = query.about, avatar = query.avatar?.large, banner = query.bannerImage, - color = query.options?.profileColor?.toHexString(), + color = query.options?.profileColor, + // TODO: Replace with string resources. stats = listOfNotNull( query.statistics?.anime?.count?.toString()?.let { Stat("TOTAL\nANIME", it) @@ -205,36 +205,6 @@ data class User( ).toImmutableList() ) - /** Profile highlight color (blue, purple, pink, orange, red, green, gray) */ - enum class ProfileColors { - BLUE, PURPLE, PINK, ORANGE, RED, GREEN, GRAY; - - companion object { - fun safeValueOf(rawValue: String): ProfileColors? = try { - ProfileColors.valueOf(rawValue) - } catch (e: IllegalArgumentException) { - Log.e(EXCEPTION_TAG, "safeValueOf: $e; Profile color $rawValue doesn't exist!.") - null - } - - internal fun String?.toHexString(): String { - val color = when(this?.let { ProfileColors.safeValueOf(it.uppercase()) }) { - BLUE -> "#007BA7" - PURPLE -> "#E0AFFF" - PINK -> "#F2BDCD" - ORANGE -> "#F2B949" - RED -> "#FA5053" - GREEN -> "#0BDA51" - GRAY -> "#D9D9D9" - null -> "#FF8DA1" - } - Log.d("whatiscolor", "toHexString: $color") - - return color - } - } - } - enum class Favouritables { Anime, Manga, @@ -268,4 +238,17 @@ data class User( fun String?.sanitize() = safeValueOf(this) } } + + companion object { + fun profileColorToHex(profileColor: String?) = when(profileColor) { + "blue" -> "#007BA7" + "purple" -> "#E0AFFF" + "pink" -> "#F2BDCD" + "orange" -> "#F2B949" + "red" -> "#FA5053" + "green" -> "#0BDA51" + "gray" -> "#D9D9D9" + else -> "#007BA7" + } + } } 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 d0b7f87e..33bc71c3 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 @@ -20,6 +20,7 @@ private const val IS_NSFW_ENABLED = false private const val DEFAULT_LANGUAGE_KEY = "DEFAULT" private const val SHOW_USER_DESCRIPTION = true private const val USE_PROFILE_COLOR = true +private const val PROFILE_COLOR = "blue" private const val IS_DEV_OPTIONS_ENABLED = false /** @@ -163,6 +164,12 @@ class PreferencesRepository internal constructor( dataStore.setValue(useProfileColorKey, useProfileColor) } + private val profileColorKey = stringPreferencesKey("profile_color") + val profileColor = dataStore.getValue(profileColorKey, PROFILE_COLOR) + suspend fun setProfileColor(profileColor: String?) { + dataStore.setValue(profileColorKey, profileColor) + } + // region developer options private val isDevOptionsEnabledKey = booleanPreferencesKey("dev_options_enabled") val isDevOptionsEnabled = dataStore.getValue(isDevOptionsEnabledKey, IS_DEV_OPTIONS_ENABLED) 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 e5a5c8ab..0bc63ac2 100644 --- a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt @@ -82,6 +82,7 @@ import androidx.core.graphics.toColorInt import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import coil3.compose.AsyncImage import com.imashnake.animite.api.anilist.sanitize.profile.User +import com.imashnake.animite.api.anilist.sanitize.profile.User.Companion.profileColorToHex import com.imashnake.animite.banner.NestedScrollBannerLayout import com.imashnake.animite.core.resource.Resource import com.imashnake.animite.core.ui.LocalPaddings @@ -140,6 +141,7 @@ fun ProfileScreen( val isLoggedIn by viewModel.isLoggedIn.collectAsState(initial = false) val useProfileColor by viewModel.useProfileColor.collectAsState(initial = true) + val profileColor by viewModel.profileColor.collectAsState(initial = "blue") val viewerAvatar by viewModel.viewerAvatar.collectAsState(initial = "") val viewer by viewModel.viewer.collectAsState() val viewerAnimeLists by viewModel.viewerAnimeLists.collectAsState() @@ -168,7 +170,7 @@ fun ProfileScreen( MaterialTheme( colorScheme = if (useProfileColor) { rememberColorSchemeFor( - color = color?.toColorInt(), + color = profileColorToHex(profileColor).toColorInt(), useDarkTheme = useDarkTheme, isAmoled = isAmoled ) 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 089dde3f..ca8d3b14 100644 --- a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileViewModel.kt +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileViewModel.kt @@ -46,6 +46,7 @@ class ProfileViewModel @Inject constructor( .map { !it.isNullOrEmpty() } val useProfileColor = preferencesRepository.useProfileColor.filterNotNull() + val profileColor = preferencesRepository.profileColor.filterNotNull() val viewer = combine( flow = refreshTrigger.onStart { emit(Unit) }, @@ -56,6 +57,10 @@ class ProfileViewModel @Inject constructor( }.asResource().onEach { it.data?.let { user -> preferencesRepository.setViewerId(user.id) + // Don't update with cached color + if (useNetwork) { + preferencesRepository.setProfileColor(user.color) + } preferencesRepository.setAnimeListOrder(user.animeListOrder) preferencesRepository.setMangaListOrder(user.mangaListOrder) } diff --git a/settings/build.gradle.kts b/settings/build.gradle.kts index 955d5e7c..9bbc3ecc 100644 --- a/settings/build.gradle.kts +++ b/settings/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(projects.api.anilist) implementation(projects.api.preferences) implementation(projects.core.model) + implementation(projects.core.resource) implementation(projects.core.ui) implementation(projects.banner) implementation(projects.media) diff --git a/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsPage.kt b/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsPage.kt index ac178eb9..c7be5c2f 100644 --- a/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsPage.kt +++ b/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsPage.kt @@ -40,7 +40,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -90,6 +90,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.imashnake.animite.api.anilist.sanitize.media.Media import com.imashnake.animite.banner.BannerLayout import com.imashnake.animite.banner.MountFuji +import com.imashnake.animite.core.resource.Resource.Companion.loading import com.imashnake.animite.core.ui.DayPart import com.imashnake.animite.core.ui.Density import com.imashnake.animite.core.ui.LocalPaddings @@ -100,6 +101,7 @@ import com.imashnake.animite.core.ui.ext.horizontalOnly import com.imashnake.animite.core.ui.layout.TranslucentStatusBarLayout import com.imashnake.animite.core.ui.rememberDefaultPaddings import com.imashnake.animite.media.ext.res +import com.materialkolor.ktx.darken import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.serialization.Serializable @@ -132,8 +134,6 @@ fun SettingsPage( val selectedDensity by viewModel.density.collectAsState(initial = Density.COMFY.name) val isNsfwEnabled by viewModel.isNsfwEnabled.collectAsState(initial = false) - val showUserDescription by viewModel.showUserDescription.collectAsState(initial = true) - val useProfileColor by viewModel.useProfileColor.collectAsState(initial = true) val selectedLanguage by viewModel.language.collectAsState(initial = Media.Language.DEFAULT.name) val listSize by viewModel.listSize.collectAsState(initial = 10) @@ -142,6 +142,10 @@ fun SettingsPage( val mangaLists by viewModel.mangaList.collectAsState(initial = persistentListOf()) val mangaListsIndices by viewModel.mangaListsIndices.collectAsState(initial = byteArrayOf(1, 2, 3, 4, 5)) + val showUserDescription by viewModel.showUserDescription.collectAsState(initial = true) + val useProfileColor by viewModel.useProfileColor.collectAsState(initial = true) + val profileColor by viewModel.profileColor.collectAsState(initial = loading()) + val isDevOptionsEnabled by viewModel.isDevOptionsEnabled.collectAsState(initial = false) val isDarkMode = selectedTheme == Theme.DARK.name || @@ -260,7 +264,7 @@ fun SettingsPage( thumbContent = { if (useSystemColorScheme) { Icon( - imageVector = Icons.Filled.Check, + imageVector = Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize), tint = MaterialTheme.colorScheme.primary @@ -284,7 +288,7 @@ fun SettingsPage( thumbContent = { if (isAmoled) { Icon( - imageVector = Icons.Filled.Check, + imageVector = Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize), tint = MaterialTheme.colorScheme.primary @@ -564,17 +568,58 @@ fun SettingsPage( } else HapticFeedbackType.ToggleOn ) } + } + }, + onItemLongClick = {}, + itemSubContent = { + when (it) { 1 -> { - viewModel.setUseProfileColor(!useProfileColor) - haptic.performHapticFeedback( - hapticFeedbackType = if (useProfileColor) { - HapticFeedbackType.ToggleOff - } else HapticFeedbackType.ToggleOn - ) + Row( + horizontalArrangement = Arrangement.spacedBy( + ButtonGroupDefaults.ConnectedSpaceBetween + ), + modifier = Modifier.fillMaxWidth() + ) { + ProfileColor.entries.fastForEachIndexed { index, entry -> + ToggleButton( + checked = profileColor.data.equals(entry.name, ignoreCase = true) && useProfileColor, + onCheckedChange = { + viewModel.setProfileColor(entry.name.lowercase()) + haptic.performHapticFeedback(HapticFeedbackType.SegmentTick) + }, + shapes = when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + ProfileColor.entries.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + enabled = useProfileColor, + colors = ToggleButtonDefaults.toggleButtonColors( + containerColor = entry.color, + contentColor = entry.color.darken(4f), + checkedContainerColor = entry.color, + checkedContentColor = entry.color.darken(4f), + ), + modifier = Modifier.weight(1f) + ) { + if (profileColor.data.equals(entry.name, ignoreCase = true) && useProfileColor) { + entry.label?.let { id -> + Text( + text = stringResource(id), + fontSize = 7.sp, + maxLines = 1 + ) + } ?: Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + } + } + } } } }, - onItemLongClick = {}, isDarkMode = isDarkMode, ) { index -> when (index) { @@ -602,7 +647,6 @@ fun SettingsPage( ) } 1 -> { - // TODO: Add color options and mutate. Switch( checked = useProfileColor, onCheckedChange = { @@ -760,7 +804,8 @@ private fun Items( onItemClick: (Int) -> Unit, onItemLongClick: (Int) -> Unit, itemCustomIcon: @Composable (() -> Unit)? = null, - itemContent: @Composable (Int) -> Unit, + itemSubContent: @Composable ((Int) -> Unit)? = null, + itemContent: @Composable (Int) -> Unit ) { CompositionLocalProvider(LocalPaddings provides rememberDefaultPaddings()) { Column( @@ -795,7 +840,8 @@ private fun Items( onItemLongClick = { onItemLongClick(index) }, background = if (isDarkMode) { MaterialTheme.colorScheme.surfaceContainer - } else MaterialTheme.colorScheme.surface + } else MaterialTheme.colorScheme.surface, + subContent = { itemSubContent?.invoke(index) } ) { itemContent(index) } @@ -835,6 +881,7 @@ private fun HorizontalItem( onItemLongClick: () -> Unit, modifier: Modifier = Modifier, customIcon: @Composable (() -> Unit)? = null, + subContent: @Composable (() -> Unit)? = null, content: @Composable () -> Unit, ) { CompositionLocalProvider( @@ -842,9 +889,8 @@ private fun HorizontalItem( // Remove default M3 padding LocalMinimumInteractiveComponentSize provides 0.dp, ) { - Row( - horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.medium), - verticalAlignment = Alignment.CenterVertically, + Column( + verticalArrangement = Arrangement.spacedBy(LocalPaddings.current.medium), modifier = modifier .fillMaxWidth() .clip(shape) @@ -855,21 +901,27 @@ private fun HorizontalItem( .background(background) .padding(LocalPaddings.current.medium) ) { - if (customIcon != null) { - customIcon() - } else { - Icon( - imageVector = ImageVector.vectorResource(item.second.icon), - contentDescription = null, + Row( + horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.medium), + verticalAlignment = Alignment.CenterVertically, + ) { + if (customIcon != null) { + customIcon() + } else { + Icon( + imageVector = ImageVector.vectorResource(item.second.icon), + contentDescription = null, + ) + } + Text( + text = stringResource(item.second.label), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), ) + content() } - Text( - text = stringResource(item.second.label), - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f), - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f), - ) - content() + subContent?.invoke() } } } @@ -1201,7 +1253,7 @@ private fun PreviewItems() { thumbContent = { if (useColorScheme) { Icon( - imageVector = Icons.Filled.Check, + imageVector = Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize), ) @@ -1244,5 +1296,18 @@ enum class Theme(@param:StringRes val theme: Int) { DEVICE_THEME(R.string.system), } +enum class ProfileColor( + val color: Color, + @param:StringRes val label: Int? = null, +) { + PURPLE(Color(0xFFC262FF)), + BLUE(Color(0xFF0BA1DA), R.string.mlue), + GREEN(Color(0xFF06C94B)), + ORANGE(Color(0xFFEE9500)), + PINK(Color(0xFFFF527E)), + RED(Color(0xFFFF3131)), + GRAY(Color(0xFF989898)); +} + @Serializable data object SettingsPage 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 feab5647..48632e40 100644 --- a/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsViewModel.kt +++ b/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsViewModel.kt @@ -2,21 +2,31 @@ package com.imashnake.animite.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.imashnake.animite.api.anilist.AnilistUserRepository import com.imashnake.animite.api.anilist.sanitize.media.Media import com.imashnake.animite.api.preferences.PreferencesRepository import com.imashnake.animite.core.model.AnimeList import com.imashnake.animite.core.model.MangaList +import com.imashnake.animite.core.resource.Resource +import com.imashnake.animite.core.resource.Resource.Companion.asResource import com.imashnake.animite.core.ui.Density import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class SettingsViewModel @Inject constructor( + private val userRepository: AnilistUserRepository, private val preferencesRepository: PreferencesRepository, ) : ViewModel() { val theme = preferencesRepository.theme.filterNotNull() @@ -42,9 +52,6 @@ class SettingsViewModel @Inject constructor( }.orEmpty().filterNotNull().toImmutableList() } val animeListsIndices = preferencesRepository.animeListsIndices.filterNotNull() - val showUserDescription = preferencesRepository.showUserDescription.filterNotNull() - val useProfileColor = preferencesRepository.useProfileColor.filterNotNull() - val mangaList = preferencesRepository.mangaListsIndices.map { indices -> indices?.map { // each int from +-1 to +-3 maps to a MangaList @@ -57,6 +64,21 @@ class SettingsViewModel @Inject constructor( }.orEmpty().filterNotNull().toImmutableList() } val mangaListsIndices = preferencesRepository.mangaListsIndices.filterNotNull() + val showUserDescription = preferencesRepository.showUserDescription.filterNotNull() + val useProfileColor = preferencesRepository.useProfileColor.filterNotNull() + val profileColor = preferencesRepository.profileColor + .filterNotNull() + .flatMapLatest { + userRepository.updateUser(it).onEach { profileColor -> + preferencesRepository.setProfileColor(profileColor.getOrNull()) + } + } + .asResource() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(1000), + initialValue = Resource.loading(), + ) fun setTheme(theme: Theme) = viewModelScope.launch(Dispatchers.IO) { preferencesRepository.setTheme(theme.name) @@ -93,6 +115,10 @@ class SettingsViewModel @Inject constructor( preferencesRepository.setUseProfileColor(useProfileColor) } + fun setProfileColor(profileColor: String) = viewModelScope.launch(Dispatchers.IO) { + preferencesRepository.setProfileColor(profileColor) + } + fun setDevOptions(enabled: Boolean) = viewModelScope.launch(Dispatchers.IO) { preferencesRepository.setDevOptionsEnabled(enabled) } diff --git a/settings/src/main/res/values/strings.xml b/settings/src/main/res/values/strings.xml index 63ebb277..ee0d0efa 100644 --- a/settings/src/main/res/values/strings.xml +++ b/settings/src/main/res/values/strings.xml @@ -23,6 +23,7 @@ Profile Show description Set profile color + mlue About