fix crashes

This commit is contained in:
Phillip Thelen 2024-04-19 11:52:13 +02:00
parent cfbbe092d7
commit 695076b5db
11 changed files with 920 additions and 518 deletions

View file

@ -105,6 +105,9 @@
android:name="com.habitrpg.android.habitica.ui.fragments.inventory.customization.AvatarOverviewFragment"
android:label="@string/sidebar_avatar" >
<deepLink app:uri="habitica.com/user/avatar" />
<action
android:id="@+id/openComposeAvatarDetail"
app:destination="@id/ComposeAvatarCustomizationFragment" />
<action
android:id="@+id/openAvatarDetail"
app:destination="@id/avatarCustomizationFragment" />
@ -132,7 +135,7 @@
android:label="@string/sidebar_equipment" >
<action
android:id="@+id/openAvatarDetail"
app:destination="@id/avatarCustomizationFragment" />
app:destination="@id/ComposeAvatarCustomizationFragment" />
<action
android:id="@+id/openEquipmentDetail"
app:destination="@id/equipmentDetailFragment" />
@ -298,6 +301,17 @@
app:argType="integer"
android:defaultValue="0"/>
</fragment>
<fragment
android:id="@+id/ComposeAvatarCustomizationFragment"
android:name="com.habitrpg.android.habitica.ui.fragments.inventory.customization.ComposeAvatarCustomizationFragment"
android:label="@string/sidebar_avatar" >
<argument
android:name="type"
app:argType="string" />
<argument
android:name="category"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/avatarCustomizationFragment"
android:name="com.habitrpg.android.habitica.ui.fragments.inventory.customization.AvatarCustomizationFragment"

View file

@ -143,7 +143,7 @@
</entry>
<entry>
<key>enableCustomizationShop</key>
<value>true</value>
<value>false</value>
</entry>
</defaultsMap>
<!-- END xml_defaults -->

View file

@ -230,13 +230,14 @@ class UserRepositoryImpl(
override suspend fun changeCustomDayStart(dayStartTime: Int): User? {
val updateObject = HashMap<String, Any>()
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

View file

@ -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

View file

@ -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<FragmentNewsBinding>() {
@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<FragmentNewsBinding>() {
@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() {

View file

@ -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<Customization>()
val activeCustomization = mutableStateOf<String?>(null)
val userSize = mutableStateOf("slim")
val hairColor = mutableStateOf<String?>(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<FragmentComposeBinding>(),
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<List<OwnedCustomization>>(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<Customization>()
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<Customization>, 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<FragmentRefreshRecyclerviewBinding>(),
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<List<OwnedCustomization>>(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<Customization>()
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<OwnedCustomization>,
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
}
}
}

View file

@ -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?) {

View file

@ -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<Customization>()
val activeCustomization = mutableStateOf<String?>(null)
val userSize = mutableStateOf("slim")
val hairColor = mutableStateOf<String?>(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<FragmentComposeBinding>(),
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<List<OwnedCustomization>>(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<Customization>()
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<Customization>, 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)
}
}
}
}
}
}

View file

@ -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)

View file

@ -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

View file

@ -1,2 +1,2 @@
NAME=4.3.6
CODE=7181
CODE=7221