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