From 695076b5db0cd28d2a2b388ecbed161a2d44ed18 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Fri, 19 Apr 2024 11:52:13 +0200 Subject: [PATCH] fix crashes --- Habitica/res/navigation/navigation.xml | 16 +- Habitica/res/xml/remote_config_defaults.xml | 2 +- .../data/implementation/UserRepositoryImpl.kt | 13 +- .../ui/fragments/NavigationDrawerFragment.kt | 1 + .../habitica/ui/fragments/NewsFragment.kt | 11 +- .../AvatarCustomizationFragment.kt | 858 ++++++++---------- .../customization/AvatarOverviewFragment.kt | 23 +- .../ComposeAvatarCustomizationFragment.kt | 499 ++++++++++ .../PurchaseDialogCustomizationContent.kt | 2 +- fastlane/changelog.txt | 11 +- version.properties | 2 +- 11 files changed, 920 insertions(+), 518 deletions(-) create mode 100644 Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/inventory/customization/ComposeAvatarCustomizationFragment.kt diff --git a/Habitica/res/navigation/navigation.xml b/Habitica/res/navigation/navigation.xml index 8ce88bf80..541709331 100644 --- a/Habitica/res/navigation/navigation.xml +++ b/Habitica/res/navigation/navigation.xml @@ -105,6 +105,9 @@ android:name="com.habitrpg.android.habitica.ui.fragments.inventory.customization.AvatarOverviewFragment" android:label="@string/sidebar_avatar" > + @@ -132,7 +135,7 @@ android:label="@string/sidebar_equipment" > + app:destination="@id/ComposeAvatarCustomizationFragment" /> @@ -298,6 +301,17 @@ app:argType="integer" android:defaultValue="0"/> + + + + enableCustomizationShop - true + false diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/UserRepositoryImpl.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/UserRepositoryImpl.kt index d7b6b99cb..8eeeb9209 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/UserRepositoryImpl.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/UserRepositoryImpl.kt @@ -230,13 +230,14 @@ class UserRepositoryImpl( override suspend fun changeCustomDayStart(dayStartTime: Int): User? { val updateObject = HashMap() updateObject["dayStart"] = dayStartTime - return apiClient.changeCustomDayStart(updateObject) + val newUser = apiClient.changeCustomDayStart(updateObject) + return mergeWithExistingUser(newUser) } override suspend fun updateLanguage(languageCode: String): User? { val user = updateUser("preferences.language", languageCode) apiClient.languageCode = languageCode - return user + return mergeWithExistingUser(user) } override suspend fun resetAccount(password: String): User? { @@ -439,6 +440,14 @@ class UserRepositoryImpl( return localRepository.getLiveObject(user) } + private suspend fun mergeWithExistingUser(newUser: User?): User? { + val oldUser = localRepository.getUser(currentUserID).firstOrNull() + if (newUser == null) { + return oldUser + } + return mergeUser(oldUser, newUser) + } + private fun mergeUser(oldUser: User?, newUser: User): User { if (oldUser == null || !oldUser.isValid) { return oldUser ?: newUser diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/NavigationDrawerFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/NavigationDrawerFragment.kt index 0763f54f1..87af495c2 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/NavigationDrawerFragment.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/NavigationDrawerFragment.kt @@ -160,6 +160,7 @@ class NavigationDrawerFragment : DialogFragment() { .collect { pair -> val gearEvent = pair.first.events.firstOrNull { it.gear } createUpdatingJob("seasonal", { + if (gearEvent?.isValid == false) return@createUpdatingJob false gearEvent?.isCurrentlyActive == true || pair.second.isNotEmpty() }, { val diff = (gearEvent?.end?.time ?: 0) - Date().time diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/NewsFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/NewsFragment.kt index 81e5b8053..a773c58e9 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/NewsFragment.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/NewsFragment.kt @@ -10,16 +10,20 @@ import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.lifecycle.lifecycleScope -import com.habitrpg.android.habitica.R import com.habitrpg.android.habitica.databinding.FragmentNewsBinding -import com.habitrpg.common.habitica.helpers.MainNavigationController +import com.habitrpg.common.habitica.api.HostConfig import com.habitrpg.common.habitica.helpers.ExceptionHandler +import com.habitrpg.common.habitica.helpers.MainNavigationController import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import javax.inject.Inject @AndroidEntryPoint class NewsFragment : BaseMainFragment() { + @Inject + lateinit var hostConfig: HostConfig + override var binding: FragmentNewsBinding? = null override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentNewsBinding { @@ -52,14 +56,13 @@ class NewsFragment : BaseMainFragment() { @SuppressLint("SetJavaScriptEnabled") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val address = context?.getString(R.string.base_url) val webSettings = binding?.newsWebview?.settings webSettings?.javaScriptEnabled = true webSettings?.domStorageEnabled = true binding?.newsWebview?.webViewClient = webviewClient binding?.newsWebview?.webChromeClient = object : WebChromeClient() { } - binding?.newsWebview?.loadUrl("$address/static/new-stuff") + binding?.newsWebview?.loadUrl("${hostConfig.address}/static/new-stuff") } override fun onResume() { diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/inventory/customization/AvatarCustomizationFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/inventory/customization/AvatarCustomizationFragment.kt index 3ad1ff41c..2d2fa787f 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/inventory/customization/AvatarCustomizationFragment.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/inventory/customization/AvatarCustomizationFragment.kt @@ -1,499 +1,361 @@ -package com.habitrpg.android.habitica.ui.fragments.inventory.customization - -import android.graphics.PorterDuff -import android.graphics.Typeface -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.CheckBox -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.platform.rememberNestedScrollInteropConnection -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModel -import androidx.lifecycle.lifecycleScope -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.habitrpg.android.habitica.R -import com.habitrpg.android.habitica.data.CustomizationRepository -import com.habitrpg.android.habitica.data.InventoryRepository -import com.habitrpg.android.habitica.databinding.BottomSheetBackgroundsFilterBinding -import com.habitrpg.android.habitica.databinding.FragmentComposeBinding -import com.habitrpg.android.habitica.helpers.Analytics -import com.habitrpg.android.habitica.helpers.AppConfigManager -import com.habitrpg.android.habitica.models.CustomizationFilter -import com.habitrpg.android.habitica.models.inventory.Customization -import com.habitrpg.android.habitica.models.user.OwnedCustomization -import com.habitrpg.android.habitica.models.user.User -import com.habitrpg.android.habitica.ui.fragments.BaseMainFragment -import com.habitrpg.android.habitica.ui.helpers.ToolbarColorHelper -import com.habitrpg.android.habitica.ui.theme.colors -import com.habitrpg.android.habitica.ui.viewmodels.MainUserViewModel -import com.habitrpg.android.habitica.ui.views.PixelArtView -import com.habitrpg.android.habitica.ui.views.dialogs.HabiticaBottomSheetDialog -import com.habitrpg.common.habitica.extensions.getThemeColor -import com.habitrpg.common.habitica.extensions.setTintWith -import com.habitrpg.common.habitica.helpers.ExceptionHandler -import com.habitrpg.common.habitica.helpers.MainNavigationController -import com.habitrpg.common.habitica.helpers.launchCatching -import com.habitrpg.common.habitica.theme.HabiticaTheme -import com.habitrpg.common.habitica.views.ComposableAvatarView -import com.habitrpg.shared.habitica.models.Avatar -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import javax.inject.Inject - -class CustomizationViewModel : ViewModel() { - var type: String? = null - var category: String? = null - - val customizations = mutableStateListOf() - val activeCustomization = mutableStateOf(null) - - val userSize = mutableStateOf("slim") - val hairColor = mutableStateOf(null) - - val typeNameId: Int - get() = when (type) { - "shirt" -> R.string.avatar_shirts - "skin" -> R.string.avatar_skins - "hair" -> { - when (category) { - "color" -> R.string.avatar_hair_colors - "base" -> R.string.avatar_hair_styles - "bangs" -> R.string.avatar_hair_bangs - "mustache" -> R.string.avatar_mustaches - "beard" -> R.string.avatar_beards - "flower" -> R.string.avatar_accents - else -> R.string.avatar_hair - } - } - - "background" -> R.string.standard_backgrounds - else -> R.string.customizations - } -} - -@AndroidEntryPoint -class AvatarCustomizationFragment : - BaseMainFragment(), - SwipeRefreshLayout.OnRefreshListener { - - private var filterMenuItem: MenuItem? = null - override var binding: FragmentComposeBinding? = null - - private val viewModel: CustomizationViewModel by viewModels() - - override fun createBinding( - inflater: LayoutInflater, - container: ViewGroup? - ): FragmentComposeBinding { - return FragmentComposeBinding.inflate(inflater, container, false) - } - - @Inject - lateinit var configManager: AppConfigManager - - @Inject - lateinit var customizationRepository: CustomizationRepository - - @Inject - lateinit var inventoryRepository: InventoryRepository - - @Inject - lateinit var userViewModel: MainUserViewModel - - var type: String? = null - var category: String? = null - private var activeCustomization: String? = null - - private val currentFilter = MutableStateFlow(CustomizationFilter(false, true)) - private val ownedCustomizations = MutableStateFlow>(emptyList()) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - showsBackButton = true - hidesToolbar = true - - val view = super.onCreateView(inflater, container, savedInstanceState) - binding?.composeView?.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - HabiticaTheme { - val userSize by viewModel.userSize - val hairColor by viewModel.hairColor - val activeCustomization by viewModel.activeCustomization - val avatar by userViewModel.user.observeAsState() - AvatarCustomizationView(avatar = avatar, configManager = configManager, viewModel.customizations, userSize, hairColor, type, stringResource(viewModel.typeNameId), activeCustomization) { customization -> - lifecycleScope.launchCatching { - if (customization.identifier?.isNotBlank() != true) { - userRepository.useCustomization(type ?: "", category, activeCustomization ?: "") - } else if (customization.type == "background" && ownedCustomizations.value.firstOrNull { it.key == customization.identifier } == null) { - userRepository.unlockPath(customization) - userRepository.retrieveUser(false, true, true) - } else { - userRepository.useCustomization( - customization.type ?: "", - customization.category, - customization.identifier ?: "" - ) - } - } - } - } - } - } - return view - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - arguments?.let { - val args = AvatarCustomizationFragmentArgs.fromBundle(it) - type = args.type - viewModel.type = type - if (args.category.isNotEmpty()) { - category = args.category - viewModel.category = category - } - currentFilter.value.ascending = type != "background" - } - this.loadCustomizations() - - userViewModel.user.observe(viewLifecycleOwner) { updateUser(it) } - - lifecycleScope.launchCatching { - currentFilter.collect { - Log.e("NewFilter", it.toString()) - } - } - - Analytics.sendNavigationEvent("$type screen") - } - - override fun onDestroy() { - customizationRepository.close() - super.onDestroy() - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - inflater.inflate(R.menu.menu_list_customizations, menu) - - filterMenuItem = menu.findItem(R.id.action_filter) - if (type == "background") { - updateFilterIcon() - } else { - filterMenuItem?.isVisible = false - } - - mainActivity?.toolbar?.let { - val color = ContextCompat.getColor(requireContext(), R.color.window_background) - ToolbarColorHelper.colorizeToolbar(it, mainActivity, backgroundColor = color) - requireActivity().window.statusBarColor = color - } - } - - private fun updateFilterIcon() { - if (!currentFilter.value.isFiltering) { - filterMenuItem?.setIcon(R.drawable.ic_action_filter_list) - context?.let { - val filterIcon = ContextCompat.getDrawable(it, R.drawable.ic_action_filter_list) - filterIcon?.setTintWith(it.getThemeColor(R.attr.headerTextColor), PorterDuff.Mode.MULTIPLY) - filterMenuItem?.setIcon(filterIcon) - } - } else { - context?.let { - val filterIcon = ContextCompat.getDrawable(it, R.drawable.ic_filters_active) - filterIcon?.setTintWith(it.getThemeColor(R.attr.textColorPrimaryDark), PorterDuff.Mode.MULTIPLY) - filterMenuItem?.setIcon(filterIcon) - } - } - } - - @Suppress("ReturnCount") - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_filter -> { - showFilterDialog() - return true - } - } - return super.onOptionsItemSelected(item) - } - - private fun loadCustomizations() { - val type = this.type ?: return - lifecycleScope.launchCatching { - customizationRepository.getCustomizations(type, category, false) - .combine(currentFilter) { customizations, filter -> Pair(customizations, filter) } - .combine(ownedCustomizations) { pair, ownedCustomizations -> - val ownedKeys = ownedCustomizations.map { it.key } - return@combine Pair(pair.first.filter { ownedKeys.contains(it.identifier) || (it.price ?: 0) == 0 }, pair.second) - } - .map { (customizations, filter) -> - var displayedCustomizations = customizations - if (filter.isFiltering) { - displayedCustomizations = mutableListOf() - for (customization in customizations) { - if (shouldSkip(filter, customization)) continue - displayedCustomizations.add(customization) - } - } - if (!filter.ascending) { - displayedCustomizations.reversed() - } else { - displayedCustomizations - } - } - .collect { customizations -> - viewModel.customizations.clear() - viewModel.customizations.addAll(customizations) - } - } - } - - private fun shouldSkip( - filter: CustomizationFilter, - customization: Customization - ): Boolean { - return if (filter.onlyPurchased) { - true - } else { - filter.months.isNotEmpty() && !filter.months.contains(customization.customizationSet?.substringAfter('.')) - } - } - - fun updateUser(user: User?) { - if (user == null) return - this.updateActiveCustomization(user) - ownedCustomizations.value = user.purchased?.customizations?.filter { it.type == this.type && it.purchased } ?: emptyList() - viewModel.userSize.value = user.preferences?.size ?: "slim" - viewModel.hairColor.value = user.preferences?.hair?.color - } - - private fun updateActiveCustomization(user: User) { - if (this.type == null || user.preferences == null) { - return - } - val prefs = user.preferences - val activeCustomization = when (this.type) { - "skin" -> prefs?.skin - "shirt" -> prefs?.shirt - "background" -> prefs?.background - "chair" -> prefs?.chair - "hair" -> when (this.category) { - "bangs" -> prefs?.hair?.bangs.toString() - "base" -> prefs?.hair?.base.toString() - "color" -> prefs?.hair?.color - "flower" -> prefs?.hair?.flower.toString() - "beard" -> prefs?.hair?.beard.toString() - "mustache" -> prefs?.hair?.mustache.toString() - else -> "" - } - - else -> "" - } - if (activeCustomization != null) { - this.activeCustomization = activeCustomization - viewModel.activeCustomization.value = activeCustomization - } - } - - override fun onRefresh() { - lifecycleScope.launch(ExceptionHandler.coroutine()) { - userRepository.retrieveUser(true, true) - } - } - - private fun showFilterDialog() { - val filter = currentFilter.value - val context = context ?: return - val dialog = HabiticaBottomSheetDialog(context) - val binding = BottomSheetBackgroundsFilterBinding.inflate(layoutInflater) - binding.showMeWrapper.check(if (filter.onlyPurchased) R.id.show_purchased_button else R.id.show_all_button) - binding.showMeWrapper.setOnCheckedChangeListener { _, checkedId -> - val newFilter = filter.copy() - newFilter.onlyPurchased = checkedId == R.id.show_purchased_button - currentFilter.value = newFilter - } - binding.clearButton.setOnClickListener { - currentFilter.value = CustomizationFilter(false, type != "background") - dialog.dismiss() - } - if (type == "background") { - binding.sortByWrapper.check(if (filter.ascending) R.id.oldest_button else R.id.newest_button) - binding.sortByWrapper.setOnCheckedChangeListener { _, checkedId -> - val newFilter = filter.copy() - newFilter.ascending = checkedId == R.id.oldest_button - currentFilter.value = newFilter - } - configureMonthFilterButton(binding.januaryButton, 1, filter) - configureMonthFilterButton(binding.febuaryButton, 2, filter) - configureMonthFilterButton(binding.marchButton, 3, filter) - configureMonthFilterButton(binding.aprilButton, 4, filter) - configureMonthFilterButton(binding.mayButton, 5, filter) - configureMonthFilterButton(binding.juneButton, 6, filter) - configureMonthFilterButton(binding.julyButton, 7, filter) - configureMonthFilterButton(binding.augustButton, 8, filter) - configureMonthFilterButton(binding.septemberButton, 9, filter) - configureMonthFilterButton(binding.octoberButton, 10, filter) - configureMonthFilterButton(binding.novemberButton, 11, filter) - configureMonthFilterButton(binding.decemberButton, 12, filter) - } else { - binding.sortByTitle.visibility = View.GONE - binding.sortByWrapper.visibility = View.GONE - binding.monthReleasedTitle.visibility = View.GONE - binding.monthReleasedWrapper.visibility = View.GONE - } - dialog.setContentView(binding.root) - dialog.setOnDismissListener { updateFilterIcon() } - dialog.show() - } - - private fun configureMonthFilterButton(button: CheckBox, value: Int, filter: CustomizationFilter) { - val identifier = value.toString().padStart(2, '0') - button.isChecked = filter.months.contains(identifier) - button.text - button.setOnCheckedChangeListener { _, isChecked -> - val newFilter = filter.copy() - newFilter.months = mutableListOf() - newFilter.months.addAll(currentFilter.value.months) - if (!isChecked && newFilter.months.contains(identifier)) { - button.typeface = Typeface.create("sans-serif", Typeface.NORMAL) - newFilter.months.remove(identifier) - } else if (isChecked && !newFilter.months.contains(identifier)) { - button.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - newFilter.months.add(identifier) - } - currentFilter.value = newFilter - } - } -} - -@Composable -private fun AvatarCustomizationView(avatar: Avatar?, configManager: AppConfigManager, customizations: List, userSize: String, hairColor: String?, type: String?, typeName: String, activeCustomization: String?, onSelect: (Customization) -> Unit) { - val nestedScrollInterop = rememberNestedScrollInteropConnection() - val totalWidth = LocalConfiguration.current.screenWidthDp.dp - val horizontalPadding = (totalWidth - (84.dp * 3)) / 2 - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.background(colorResource(R.color.window_background))) { - ComposableAvatarView( - avatar = avatar, configManager = configManager, modifier = Modifier - .padding(vertical = 24.dp) - .size(140.dp, 147.dp) - ) - Box( - Modifier - .background(colorResource(R.color.content_background), RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp)) - .fillMaxWidth() - .height(22.dp) - ) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(76.dp), - horizontalArrangement = Arrangement.Center, - contentPadding = PaddingValues(horizontal = horizontalPadding), - modifier = Modifier - .nestedScroll(nestedScrollInterop) - .background(colorResource(R.color.content_background)) - ) { - item(span = { GridItemSpan(3) }) { - Text( - typeName.uppercase(), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - color = colorResource(id = R.color.text_ternary), - textAlign = TextAlign.Center, - modifier = Modifier.padding(10.dp) - ) - } - if (customizations.size > 1) { - items(customizations) { customization -> - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .padding(4.dp) - .border(if (activeCustomization == customization.identifier) 2.dp else 0.dp, if (activeCustomization == customization.identifier) HabiticaTheme.colors.tintedUiMain else colorResource(R.color.transparent), RoundedCornerShape(8.dp)) - .clip(RoundedCornerShape(8.dp)) - .clickable { - onSelect(customization) - } - .background(colorResource(id = R.color.window_background))) { - if (customization.identifier.isNullOrBlank() || customization.identifier == "0") { - Image(painterResource(R.drawable.empty_slot), contentDescription = null, contentScale = ContentScale.None, modifier = Modifier.size(68.dp)) - } else { - PixelArtView( - imageName = customization.getImageName(userSize, hairColor), - Modifier.size(68.dp) - ) - } - } - } - } - item(span = { GridItemSpan(3) }) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(top = 40.dp).clickable { - MainNavigationController.navigate(R.id.customizationsShopFragment) - }) { - Image( - painterResource(if (type == "backgrounds") R.drawable.customization_background else R.drawable.customization_mix), - null, modifier = Modifier.padding(bottom = 12.dp) - ) - if (customizations.size <= 1) { - Text(stringResource(R.string.customizations_no_owned), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colorResource(R.color.text_secondary)) - Text(stringResource(R.string.customization_shop_check_out), fontSize = 13.sp, color = colorResource(R.color.text_ternary), textAlign = TextAlign.Center) - } else { - Text(stringResource(R.string.looking_for_more), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colorResource(R.color.text_secondary)) - Text(stringResource(R.string.customization_shop_more), fontSize = 13.sp, color = colorResource(R.color.text_ternary), textAlign = TextAlign.Center) - } - } - } - } - } +package com.habitrpg.android.habitica.ui.fragments.inventory.customization + +import android.graphics.PorterDuff +import android.graphics.Typeface +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.CheckBox +import androidx.core.content.ContextCompat +import androidx.core.view.doOnLayout +import androidx.lifecycle.lifecycleScope +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.flexbox.AlignItems +import com.google.android.flexbox.FlexDirection.ROW +import com.google.android.flexbox.FlexboxLayoutManager +import com.google.android.flexbox.JustifyContent +import com.habitrpg.android.habitica.R +import com.habitrpg.android.habitica.data.CustomizationRepository +import com.habitrpg.android.habitica.data.InventoryRepository +import com.habitrpg.android.habitica.databinding.BottomSheetBackgroundsFilterBinding +import com.habitrpg.android.habitica.databinding.FragmentRefreshRecyclerviewBinding +import com.habitrpg.android.habitica.helpers.Analytics +import com.habitrpg.android.habitica.models.CustomizationFilter +import com.habitrpg.android.habitica.models.inventory.Customization +import com.habitrpg.android.habitica.models.user.OwnedCustomization +import com.habitrpg.android.habitica.models.user.User +import com.habitrpg.android.habitica.ui.adapter.CustomizationRecyclerViewAdapter +import com.habitrpg.android.habitica.ui.fragments.BaseMainFragment +import com.habitrpg.android.habitica.ui.helpers.MarginDecoration +import com.habitrpg.android.habitica.ui.helpers.SafeDefaultItemAnimator +import com.habitrpg.android.habitica.ui.viewmodels.MainUserViewModel +import com.habitrpg.android.habitica.ui.views.dialogs.HabiticaBottomSheetDialog +import com.habitrpg.android.habitica.ui.views.shops.PurchaseDialog +import com.habitrpg.common.habitica.extensions.dpToPx +import com.habitrpg.common.habitica.extensions.getThemeColor +import com.habitrpg.common.habitica.extensions.setTintWith +import com.habitrpg.common.habitica.helpers.ExceptionHandler +import com.habitrpg.common.habitica.helpers.launchCatching +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class AvatarCustomizationFragment : + BaseMainFragment(), + SwipeRefreshLayout.OnRefreshListener { + + private var filterMenuItem: MenuItem? = null + override var binding: FragmentRefreshRecyclerviewBinding? = null + + override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRefreshRecyclerviewBinding { + return FragmentRefreshRecyclerviewBinding.inflate(inflater, container, false) + } + + @Inject + lateinit var customizationRepository: CustomizationRepository + + @Inject + lateinit var inventoryRepository: InventoryRepository + + @Inject + lateinit var userViewModel: MainUserViewModel + + var type: String? = null + var category: String? = null + private var activeCustomization: String? = null + + internal var adapter: CustomizationRecyclerViewAdapter = CustomizationRecyclerViewAdapter() + internal var layoutManager: FlexboxLayoutManager = FlexboxLayoutManager(mainActivity, ROW) + + private val currentFilter = MutableStateFlow(CustomizationFilter(false, true)) + private val ownedCustomizations = MutableStateFlow>(emptyList()) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + showsBackButton = true + adapter.onCustomizationSelected = { customization -> + lifecycleScope.launchCatching { + if (customization.identifier?.isNotBlank() != true) { + userRepository.useCustomization(customization.type ?: "", customization.category, activeCustomization ?: "") + } else if (customization.type == "background" && ownedCustomizations.value.firstOrNull { it.key == customization.identifier } == null) { + userRepository.unlockPath(customization) + userRepository.retrieveUser(false, true, true) + } else { + userRepository.useCustomization( + customization.type ?: "", + customization.category, + customization.identifier ?: "" + ) + } + } + } + adapter.onShowPurchaseDialog = { item -> + val dialog = PurchaseDialog(requireContext(), userRepository, inventoryRepository, item) + dialog.show() + } + + lifecycleScope.launchCatching { + inventoryRepository.getInAppRewards() + .map { rewards -> rewards.map { it.key } } + .collect { adapter.setPinnedItemKeys(it) } + } + + return super.onCreateView(inflater, container, savedInstanceState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + arguments?.let { + val args = AvatarCustomizationFragmentArgs.fromBundle(it) + type = args.type + if (args.category.isNotEmpty()) { + category = args.category + } + currentFilter.value.ascending = type != "background" + } + adapter.customizationType = type + binding?.refreshLayout?.setOnRefreshListener(this) + layoutManager = FlexboxLayoutManager(mainActivity, ROW) + layoutManager.justifyContent = JustifyContent.CENTER + layoutManager.alignItems = AlignItems.FLEX_START + binding?.recyclerView?.layoutManager = layoutManager + + binding?.recyclerView?.addItemDecoration(MarginDecoration(context)) + + binding?.recyclerView?.adapter = adapter + binding?.recyclerView?.itemAnimator = SafeDefaultItemAnimator() + this.loadCustomizations() + + userViewModel.user.observe(viewLifecycleOwner) { updateUser(it) } + + binding?.recyclerView?.doOnLayout { + adapter.columnCount = it.width / (80.dpToPx(context)) + } + + lifecycleScope.launchCatching { + currentFilter.collect { + Log.e("NewFilter", it.toString()) + } + } + + Analytics.sendNavigationEvent("$type screen") + } + + override fun onDestroy() { + customizationRepository.close() + super.onDestroy() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_list_customizations, menu) + + filterMenuItem = menu.findItem(R.id.action_filter) + updateFilterIcon() + } + + private fun updateFilterIcon() { + if (!currentFilter.value.isFiltering) { + filterMenuItem?.setIcon(R.drawable.ic_action_filter_list) + context?.let { + val filterIcon = ContextCompat.getDrawable(it, R.drawable.ic_action_filter_list) + filterIcon?.setTintWith(it.getThemeColor(R.attr.headerTextColor), PorterDuff.Mode.MULTIPLY) + filterMenuItem?.setIcon(filterIcon) + } + } else { + context?.let { + val filterIcon = ContextCompat.getDrawable(it, R.drawable.ic_filters_active) + filterIcon?.setTintWith(it.getThemeColor(R.attr.textColorPrimaryDark), PorterDuff.Mode.MULTIPLY) + filterMenuItem?.setIcon(filterIcon) + } + } + } + + @Suppress("ReturnCount") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_filter -> { + showFilterDialog() + return true + } + } + + return super.onOptionsItemSelected(item) + } + + private fun loadCustomizations() { + val type = this.type ?: return + lifecycleScope.launchCatching { + customizationRepository.getCustomizations(type, category, false) + .combine(currentFilter) { customizations, filter -> Pair(customizations, filter) } + .combine(ownedCustomizations) { pair, ownedCustomizations -> Triple(pair.first, pair.second, ownedCustomizations) } + .collect { (customizations, filter, ownedCustomizations) -> + adapter.ownedCustomizations = + ownedCustomizations.map { it.key + "_" + it.type + "_" + it.category } + if (filter.isFiltering) { + val displayedCustomizations = mutableListOf() + for (customization in customizations) { + if (shouldSkip(filter, ownedCustomizations, customization)) continue + displayedCustomizations.add(customization) + } + adapter.setCustomizations( + if (!filter.ascending) { + displayedCustomizations.reversed() + } else { + displayedCustomizations + } + ) + } else { + adapter.setCustomizations( + if (!filter.ascending) { + customizations.reversed() + } else { + customizations + } + ) + } + } + } + if (type == "hair" && (category == "beard" || category == "mustache")) { + val otherCategory = if (category == "mustache") "beard" else "mustache" + lifecycleScope.launchCatching { + customizationRepository.getCustomizations(type, otherCategory, true).collect { + adapter.additionalSetItems = it + } + } + } + } + + private fun shouldSkip( + filter: CustomizationFilter, + ownedCustomizations: List, + customization: Customization + ): Boolean { + return if (filter.onlyPurchased && ownedCustomizations.find { it.key == customization.identifier } == null) { + true + } else { + filter.months.isNotEmpty() && !filter.months.contains(customization.customizationSet?.substringAfter('.')) + } + } + + fun updateUser(user: User?) { + if (user == null) return + this.updateActiveCustomization(user) + ownedCustomizations.value = user.purchased?.customizations?.filter { it.type == this.type && it.purchased } ?: emptyList() + this.adapter.userSize = user.preferences?.size + this.adapter.hairColor = user.preferences?.hair?.color + this.adapter.gemBalance = user.gemCount + this.adapter.avatar = user + adapter.notifyDataSetChanged() + } + + private fun updateActiveCustomization(user: User) { + if (this.type == null || user.preferences == null) { + return + } + val prefs = user.preferences + val activeCustomization = when (this.type) { + "skin" -> prefs?.skin + "shirt" -> prefs?.shirt + "background" -> prefs?.background + "chair" -> prefs?.chair + "hair" -> when (this.category) { + "bangs" -> prefs?.hair?.bangs.toString() + "base" -> prefs?.hair?.base.toString() + "color" -> prefs?.hair?.color + "flower" -> prefs?.hair?.flower.toString() + "beard" -> prefs?.hair?.beard.toString() + "mustache" -> prefs?.hair?.mustache.toString() + else -> "" + } + else -> "" + } + if (activeCustomization != null) { + this.activeCustomization = activeCustomization + this.adapter.activeCustomization = activeCustomization + } + } + + override fun onRefresh() { + lifecycleScope.launch(ExceptionHandler.coroutine()) { + userRepository.retrieveUser(true, true) + binding?.refreshLayout?.isRefreshing = false + } + } + + fun showFilterDialog() { + val filter = currentFilter.value + val context = context ?: return + val dialog = HabiticaBottomSheetDialog(context) + val binding = BottomSheetBackgroundsFilterBinding.inflate(layoutInflater) + binding.showMeWrapper.check(if (filter.onlyPurchased) R.id.show_purchased_button else R.id.show_all_button) + binding.showMeWrapper.setOnCheckedChangeListener { _, checkedId -> + val newFilter = filter.copy() + newFilter.onlyPurchased = checkedId == R.id.show_purchased_button + currentFilter.value = newFilter + } + binding.clearButton.setOnClickListener { + currentFilter.value = CustomizationFilter(false, type != "background") + dialog.dismiss() + } + if (type == "background") { + binding.sortByWrapper.check(if (filter.ascending) R.id.oldest_button else R.id.newest_button) + binding.sortByWrapper.setOnCheckedChangeListener { _, checkedId -> + val newFilter = filter.copy() + newFilter.ascending = checkedId == R.id.oldest_button + currentFilter.value = newFilter + } + configureMonthFilterButton(binding.januaryButton, 1, filter) + configureMonthFilterButton(binding.febuaryButton, 2, filter) + configureMonthFilterButton(binding.marchButton, 3, filter) + configureMonthFilterButton(binding.aprilButton, 4, filter) + configureMonthFilterButton(binding.mayButton, 5, filter) + configureMonthFilterButton(binding.juneButton, 6, filter) + configureMonthFilterButton(binding.julyButton, 7, filter) + configureMonthFilterButton(binding.augustButton, 8, filter) + configureMonthFilterButton(binding.septemberButton, 9, filter) + configureMonthFilterButton(binding.octoberButton, 10, filter) + configureMonthFilterButton(binding.novemberButton, 11, filter) + configureMonthFilterButton(binding.decemberButton, 12, filter) + } else { + binding.sortByTitle.visibility = View.GONE + binding.sortByWrapper.visibility = View.GONE + binding.monthReleasedTitle.visibility = View.GONE + binding.monthReleasedWrapper.visibility = View.GONE + } + dialog.setContentView(binding.root) + dialog.setOnDismissListener { updateFilterIcon() } + dialog.show() + } + + private fun configureMonthFilterButton(button: CheckBox, value: Int, filter: CustomizationFilter) { + val identifier = value.toString().padStart(2, '0') + button.isChecked = filter.months.contains(identifier) + button.text + button.setOnCheckedChangeListener { _, isChecked -> + val newFilter = filter.copy() + newFilter.months = mutableListOf() + newFilter.months.addAll(currentFilter.value.months) + if (!isChecked && newFilter.months.contains(identifier)) { + button.typeface = Typeface.create("sans-serif", Typeface.NORMAL) + newFilter.months.remove(identifier) + } else if (isChecked && !newFilter.months.contains(identifier)) { + button.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + newFilter.months.add(identifier) + } + currentFilter.value = newFilter + } + } } \ No newline at end of file diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/inventory/customization/AvatarOverviewFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/inventory/customization/AvatarOverviewFragment.kt index 799f5d261..b528bb62b 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/inventory/customization/AvatarOverviewFragment.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/inventory/customization/AvatarOverviewFragment.kt @@ -29,6 +29,7 @@ import androidx.lifecycle.map import com.habitrpg.android.habitica.R import com.habitrpg.android.habitica.data.InventoryRepository import com.habitrpg.android.habitica.databinding.FragmentComposeScrollingBinding +import com.habitrpg.android.habitica.helpers.AppConfigManager import com.habitrpg.android.habitica.interactors.ShareAvatarUseCase import com.habitrpg.android.habitica.models.inventory.Equipment import com.habitrpg.android.habitica.ui.activities.BaseActivity @@ -56,6 +57,9 @@ open class AvatarOverviewFragment : @Inject lateinit var inventoryRepository: InventoryRepository + @Inject + lateinit var appConfigManager: AppConfigManager + override var binding: FragmentComposeScrollingBinding? = null protected var showCustomization = true @@ -119,12 +123,21 @@ open class AvatarOverviewFragment : } private fun displayCustomizationFragment(type: String, category: String?) { - MainNavigationController.navigate( - AvatarOverviewFragmentDirections.openAvatarDetail( - type, - category ?: "" + if (appConfigManager.enableCustomizationShop()) { + MainNavigationController.navigate( + AvatarOverviewFragmentDirections.openComposeAvatarDetail( + type, + category ?: "" + ) ) - ) + } else { + MainNavigationController.navigate( + AvatarOverviewFragmentDirections.openAvatarDetail( + type, + category ?: "" + ) + ) + } } private fun displayAvatarEquipmentFragment(type: String, category: String?) { diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/inventory/customization/ComposeAvatarCustomizationFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/inventory/customization/ComposeAvatarCustomizationFragment.kt new file mode 100644 index 000000000..4e4b786aa --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/inventory/customization/ComposeAvatarCustomizationFragment.kt @@ -0,0 +1,499 @@ +package com.habitrpg.android.habitica.ui.fragments.inventory.customization + +import android.graphics.PorterDuff +import android.graphics.Typeface +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.CheckBox +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModel +import androidx.lifecycle.lifecycleScope +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.habitrpg.android.habitica.R +import com.habitrpg.android.habitica.data.CustomizationRepository +import com.habitrpg.android.habitica.data.InventoryRepository +import com.habitrpg.android.habitica.databinding.BottomSheetBackgroundsFilterBinding +import com.habitrpg.android.habitica.databinding.FragmentComposeBinding +import com.habitrpg.android.habitica.helpers.Analytics +import com.habitrpg.android.habitica.helpers.AppConfigManager +import com.habitrpg.android.habitica.models.CustomizationFilter +import com.habitrpg.android.habitica.models.inventory.Customization +import com.habitrpg.android.habitica.models.user.OwnedCustomization +import com.habitrpg.android.habitica.models.user.User +import com.habitrpg.android.habitica.ui.fragments.BaseMainFragment +import com.habitrpg.android.habitica.ui.helpers.ToolbarColorHelper +import com.habitrpg.android.habitica.ui.theme.colors +import com.habitrpg.android.habitica.ui.viewmodels.MainUserViewModel +import com.habitrpg.android.habitica.ui.views.PixelArtView +import com.habitrpg.android.habitica.ui.views.dialogs.HabiticaBottomSheetDialog +import com.habitrpg.common.habitica.extensions.getThemeColor +import com.habitrpg.common.habitica.extensions.setTintWith +import com.habitrpg.common.habitica.helpers.ExceptionHandler +import com.habitrpg.common.habitica.helpers.MainNavigationController +import com.habitrpg.common.habitica.helpers.launchCatching +import com.habitrpg.common.habitica.theme.HabiticaTheme +import com.habitrpg.common.habitica.views.ComposableAvatarView +import com.habitrpg.shared.habitica.models.Avatar +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +class CustomizationViewModel : ViewModel() { + var type: String? = null + var category: String? = null + + val customizations = mutableStateListOf() + val activeCustomization = mutableStateOf(null) + + val userSize = mutableStateOf("slim") + val hairColor = mutableStateOf(null) + + val typeNameId: Int + get() = when (type) { + "shirt" -> R.string.avatar_shirts + "skin" -> R.string.avatar_skins + "hair" -> { + when (category) { + "color" -> R.string.avatar_hair_colors + "base" -> R.string.avatar_hair_styles + "bangs" -> R.string.avatar_hair_bangs + "mustache" -> R.string.avatar_mustaches + "beard" -> R.string.avatar_beards + "flower" -> R.string.avatar_accents + else -> R.string.avatar_hair + } + } + + "background" -> R.string.standard_backgrounds + else -> R.string.customizations + } +} + +@AndroidEntryPoint +class ComposeAvatarCustomizationFragment : + BaseMainFragment(), + SwipeRefreshLayout.OnRefreshListener { + + private var filterMenuItem: MenuItem? = null + override var binding: FragmentComposeBinding? = null + + private val viewModel: CustomizationViewModel by viewModels() + + override fun createBinding( + inflater: LayoutInflater, + container: ViewGroup? + ): FragmentComposeBinding { + return FragmentComposeBinding.inflate(inflater, container, false) + } + + @Inject + lateinit var configManager: AppConfigManager + + @Inject + lateinit var customizationRepository: CustomizationRepository + + @Inject + lateinit var inventoryRepository: InventoryRepository + + @Inject + lateinit var userViewModel: MainUserViewModel + + var type: String? = null + var category: String? = null + private var activeCustomization: String? = null + + private val currentFilter = MutableStateFlow(CustomizationFilter(false, true)) + private val ownedCustomizations = MutableStateFlow>(emptyList()) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + showsBackButton = true + hidesToolbar = true + + val view = super.onCreateView(inflater, container, savedInstanceState) + binding?.composeView?.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + HabiticaTheme { + val userSize by viewModel.userSize + val hairColor by viewModel.hairColor + val activeCustomization by viewModel.activeCustomization + val avatar by userViewModel.user.observeAsState() + AvatarCustomizationView(avatar = avatar, configManager = configManager, viewModel.customizations, userSize, hairColor, type, stringResource(viewModel.typeNameId), activeCustomization) { customization -> + lifecycleScope.launchCatching { + if (customization.identifier?.isNotBlank() != true) { + userRepository.useCustomization(type ?: "", category, activeCustomization ?: "") + } else if (customization.type == "background" && ownedCustomizations.value.firstOrNull { it.key == customization.identifier } == null) { + userRepository.unlockPath(customization) + userRepository.retrieveUser(false, true, true) + } else { + userRepository.useCustomization( + customization.type ?: "", + customization.category, + customization.identifier ?: "" + ) + } + } + } + } + } + } + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + arguments?.let { + val args = ComposeAvatarCustomizationFragmentArgs.fromBundle(it) + type = args.type + viewModel.type = type + if (args.category.isNotEmpty()) { + category = args.category + viewModel.category = category + } + currentFilter.value.ascending = type != "background" + } + this.loadCustomizations() + + userViewModel.user.observe(viewLifecycleOwner) { updateUser(it) } + + lifecycleScope.launchCatching { + currentFilter.collect { + Log.e("NewFilter", it.toString()) + } + } + + Analytics.sendNavigationEvent("$type screen") + } + + override fun onDestroy() { + customizationRepository.close() + super.onDestroy() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_list_customizations, menu) + + filterMenuItem = menu.findItem(R.id.action_filter) + if (type == "background") { + updateFilterIcon() + } else { + filterMenuItem?.isVisible = false + } + + mainActivity?.toolbar?.let { + val color = ContextCompat.getColor(requireContext(), R.color.window_background) + ToolbarColorHelper.colorizeToolbar(it, mainActivity, backgroundColor = color) + requireActivity().window.statusBarColor = color + } + } + + private fun updateFilterIcon() { + if (!currentFilter.value.isFiltering) { + filterMenuItem?.setIcon(R.drawable.ic_action_filter_list) + context?.let { + val filterIcon = ContextCompat.getDrawable(it, R.drawable.ic_action_filter_list) + filterIcon?.setTintWith(it.getThemeColor(R.attr.headerTextColor), PorterDuff.Mode.MULTIPLY) + filterMenuItem?.setIcon(filterIcon) + } + } else { + context?.let { + val filterIcon = ContextCompat.getDrawable(it, R.drawable.ic_filters_active) + filterIcon?.setTintWith(it.getThemeColor(R.attr.textColorPrimaryDark), PorterDuff.Mode.MULTIPLY) + filterMenuItem?.setIcon(filterIcon) + } + } + } + + @Suppress("ReturnCount") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_filter -> { + showFilterDialog() + return true + } + } + return super.onOptionsItemSelected(item) + } + + private fun loadCustomizations() { + val type = this.type ?: return + lifecycleScope.launchCatching { + customizationRepository.getCustomizations(type, category, false) + .combine(currentFilter) { customizations, filter -> Pair(customizations, filter) } + .combine(ownedCustomizations) { pair, ownedCustomizations -> + val ownedKeys = ownedCustomizations.map { it.key } + return@combine Pair(pair.first.filter { ownedKeys.contains(it.identifier) || (it.price ?: 0) == 0 }, pair.second) + } + .map { (customizations, filter) -> + var displayedCustomizations = customizations + if (filter.isFiltering) { + displayedCustomizations = mutableListOf() + for (customization in customizations) { + if (shouldSkip(filter, customization)) continue + displayedCustomizations.add(customization) + } + } + if (!filter.ascending) { + displayedCustomizations.reversed() + } else { + displayedCustomizations + } + } + .collect { customizations -> + viewModel.customizations.clear() + viewModel.customizations.addAll(customizations) + } + } + } + + private fun shouldSkip( + filter: CustomizationFilter, + customization: Customization + ): Boolean { + return if (filter.onlyPurchased) { + true + } else { + filter.months.isNotEmpty() && !filter.months.contains(customization.customizationSet?.substringAfter('.')) + } + } + + fun updateUser(user: User?) { + if (user == null) return + this.updateActiveCustomization(user) + ownedCustomizations.value = user.purchased?.customizations?.filter { it.type == this.type && it.purchased } ?: emptyList() + viewModel.userSize.value = user.preferences?.size ?: "slim" + viewModel.hairColor.value = user.preferences?.hair?.color + } + + private fun updateActiveCustomization(user: User) { + if (this.type == null || user.preferences == null) { + return + } + val prefs = user.preferences + val activeCustomization = when (this.type) { + "skin" -> prefs?.skin + "shirt" -> prefs?.shirt + "background" -> prefs?.background + "chair" -> prefs?.chair + "hair" -> when (this.category) { + "bangs" -> prefs?.hair?.bangs.toString() + "base" -> prefs?.hair?.base.toString() + "color" -> prefs?.hair?.color + "flower" -> prefs?.hair?.flower.toString() + "beard" -> prefs?.hair?.beard.toString() + "mustache" -> prefs?.hair?.mustache.toString() + else -> "" + } + + else -> "" + } + if (activeCustomization != null) { + this.activeCustomization = activeCustomization + viewModel.activeCustomization.value = activeCustomization + } + } + + override fun onRefresh() { + lifecycleScope.launch(ExceptionHandler.coroutine()) { + userRepository.retrieveUser(true, true) + } + } + + private fun showFilterDialog() { + val filter = currentFilter.value + val context = context ?: return + val dialog = HabiticaBottomSheetDialog(context) + val binding = BottomSheetBackgroundsFilterBinding.inflate(layoutInflater) + binding.showMeWrapper.check(if (filter.onlyPurchased) R.id.show_purchased_button else R.id.show_all_button) + binding.showMeWrapper.setOnCheckedChangeListener { _, checkedId -> + val newFilter = filter.copy() + newFilter.onlyPurchased = checkedId == R.id.show_purchased_button + currentFilter.value = newFilter + } + binding.clearButton.setOnClickListener { + currentFilter.value = CustomizationFilter(false, type != "background") + dialog.dismiss() + } + if (type == "background") { + binding.sortByWrapper.check(if (filter.ascending) R.id.oldest_button else R.id.newest_button) + binding.sortByWrapper.setOnCheckedChangeListener { _, checkedId -> + val newFilter = filter.copy() + newFilter.ascending = checkedId == R.id.oldest_button + currentFilter.value = newFilter + } + configureMonthFilterButton(binding.januaryButton, 1, filter) + configureMonthFilterButton(binding.febuaryButton, 2, filter) + configureMonthFilterButton(binding.marchButton, 3, filter) + configureMonthFilterButton(binding.aprilButton, 4, filter) + configureMonthFilterButton(binding.mayButton, 5, filter) + configureMonthFilterButton(binding.juneButton, 6, filter) + configureMonthFilterButton(binding.julyButton, 7, filter) + configureMonthFilterButton(binding.augustButton, 8, filter) + configureMonthFilterButton(binding.septemberButton, 9, filter) + configureMonthFilterButton(binding.octoberButton, 10, filter) + configureMonthFilterButton(binding.novemberButton, 11, filter) + configureMonthFilterButton(binding.decemberButton, 12, filter) + } else { + binding.sortByTitle.visibility = View.GONE + binding.sortByWrapper.visibility = View.GONE + binding.monthReleasedTitle.visibility = View.GONE + binding.monthReleasedWrapper.visibility = View.GONE + } + dialog.setContentView(binding.root) + dialog.setOnDismissListener { updateFilterIcon() } + dialog.show() + } + + private fun configureMonthFilterButton(button: CheckBox, value: Int, filter: CustomizationFilter) { + val identifier = value.toString().padStart(2, '0') + button.isChecked = filter.months.contains(identifier) + button.text + button.setOnCheckedChangeListener { _, isChecked -> + val newFilter = filter.copy() + newFilter.months = mutableListOf() + newFilter.months.addAll(currentFilter.value.months) + if (!isChecked && newFilter.months.contains(identifier)) { + button.typeface = Typeface.create("sans-serif", Typeface.NORMAL) + newFilter.months.remove(identifier) + } else if (isChecked && !newFilter.months.contains(identifier)) { + button.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + newFilter.months.add(identifier) + } + currentFilter.value = newFilter + } + } +} + +@Composable +private fun AvatarCustomizationView(avatar: Avatar?, configManager: AppConfigManager, customizations: List, userSize: String, hairColor: String?, type: String?, typeName: String, activeCustomization: String?, onSelect: (Customization) -> Unit) { + val nestedScrollInterop = rememberNestedScrollInteropConnection() + val totalWidth = LocalConfiguration.current.screenWidthDp.dp + val horizontalPadding = (totalWidth - (84.dp * 3)) / 2 + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.background(colorResource(R.color.window_background))) { + ComposableAvatarView( + avatar = avatar, configManager = configManager, modifier = Modifier + .padding(vertical = 24.dp) + .size(140.dp, 147.dp) + ) + Box( + Modifier + .background(colorResource(R.color.content_background), RoundedCornerShape(topStart = 22.dp, topEnd = 22.dp)) + .fillMaxWidth() + .height(22.dp) + ) + } + LazyVerticalGrid( + columns = GridCells.Adaptive(76.dp), + horizontalArrangement = Arrangement.Center, + contentPadding = PaddingValues(horizontal = horizontalPadding), + modifier = Modifier + .nestedScroll(nestedScrollInterop) + .background(colorResource(R.color.content_background)) + ) { + item(span = { GridItemSpan(3) }) { + Text( + typeName.uppercase(), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = colorResource(id = R.color.text_ternary), + textAlign = TextAlign.Center, + modifier = Modifier.padding(10.dp) + ) + } + if (customizations.size > 1) { + items(customizations) { customization -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(4.dp) + .border(if (activeCustomization == customization.identifier) 2.dp else 0.dp, if (activeCustomization == customization.identifier) HabiticaTheme.colors.tintedUiMain else colorResource(R.color.transparent), RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onSelect(customization) + } + .background(colorResource(id = R.color.window_background))) { + if (customization.identifier.isNullOrBlank() || customization.identifier == "0") { + Image(painterResource(R.drawable.empty_slot), contentDescription = null, contentScale = ContentScale.None, modifier = Modifier.size(68.dp)) + } else { + PixelArtView( + imageName = customization.getImageName(userSize, hairColor), + Modifier.size(68.dp) + ) + } + } + } + } + item(span = { GridItemSpan(3) }) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(top = 40.dp).clickable { + MainNavigationController.navigate(R.id.customizationsShopFragment) + }) { + Image( + painterResource(if (type == "backgrounds") R.drawable.customization_background else R.drawable.customization_mix), + null, modifier = Modifier.padding(bottom = 12.dp) + ) + if (customizations.size <= 1) { + Text(stringResource(R.string.customizations_no_owned), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colorResource(R.color.text_secondary)) + Text(stringResource(R.string.customization_shop_check_out), fontSize = 13.sp, color = colorResource(R.color.text_ternary), textAlign = TextAlign.Center) + } else { + Text(stringResource(R.string.looking_for_more), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colorResource(R.color.text_secondary)) + Text(stringResource(R.string.customization_shop_more), fontSize = 13.sp, color = colorResource(R.color.text_ternary), textAlign = TextAlign.Center) + } + } + } + } + } +} \ No newline at end of file diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/shops/PurchaseDialogCustomizationContent.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/shops/PurchaseDialogCustomizationContent.kt index b829f226c..68e171e89 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/shops/PurchaseDialogCustomizationContent.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/shops/PurchaseDialogCustomizationContent.kt @@ -53,7 +53,7 @@ class PurchaseDialogCustomizationContent(context: Context) : PurchaseDialogConte else -> null } layerName?.let { - layerMap[it] = shopItem.imageName + layerMap[it] = shopItem.imageName?.replace("shop_", "")?.replace("icon_", "") } binding.avatarView.setAvatar(user, layerMap) diff --git a/fastlane/changelog.txt b/fastlane/changelog.txt index 751effd6a..6c308e0bf 100644 --- a/fastlane/changelog.txt +++ b/fastlane/changelog.txt @@ -1,6 +1,7 @@ -New in 4.3.4: -- To Do reminders should show more reliably -- Added password reset option to the Account Reset and Account Delete screens -- Group Plan invites will show in the notification center -- Added the ability to report a Challenge for community violations +New in 4.3.7: +- Experience and level should update automatically after finishing a Quest +- Shop banners should now show properly during seasonal events +- Fixed an issue that would prevent the creation of new Challenges +- Fixed an issue with Settings not properly displaying selected changes +- Adjusted the conditions for when a review prompt will show - Various other bug fixes and improvements diff --git a/version.properties b/version.properties index f0b3b37ea..0c298b135 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ NAME=4.3.6 -CODE=7181 \ No newline at end of file +CODE=7221 \ No newline at end of file