Notification scheduling improvements

This commit is contained in:
Phillip Thelen 2025-01-06 14:57:34 +01:00
parent 9fe345f72e
commit 03de0bbf91
21 changed files with 289 additions and 732 deletions

View file

@ -3,7 +3,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.habitrpg.android.habitica"
android:screenOrientation="portrait"
android:installLocation="auto" >
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@ -239,10 +238,12 @@
<action android:name="REJECT_QUEST_INVITE"/>
</intent-filter>
</receiver>
<receiver android:name=".receivers.TaskAlarmBootReceiver" android:permission="android.permission.RECEIVE_BOOT_COMPLETED"
<receiver android:name=".receivers.TaskAlarmBootReceiver"
android:permission="android.permission.RECEIVE_BOOT_COMPLETED"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
</intent-filter>
</receiver>

View file

@ -227,7 +227,6 @@
android:text="@string/push_notification_system_settings_reminders"
android:textColor="@color/text_quad"
android:textSize="12sp" />
</RelativeLayout>
<com.habitrpg.android.habitica.ui.views.tasks.form.ReminderContainer
@ -235,6 +234,22 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<FrameLayout
android:id="@+id/exact_alarm_disabled_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/spacing_medium"
android:background="@drawable/layout_rounded_bg_yellow_10">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/spacing_medium"
android:text="@string/exact_alarm_system_settings_reminders"
android:textColor="@color/yellow_0"
android:textSize="12sp" />
</FrameLayout>
<LinearLayout
android:id="@+id/stat_wrapper"
android:layout_width="match_parent"

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical">
<androidx.compose.ui.platform.ComposeView
android:id="@+id/avatar_header"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<com.habitrpg.android.habitica.ui.helpers.RecyclerViewEmptySupport
android:id="@+id/recyclerView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:clipToPadding="false"
android:scrollbarSize="3dp"
android:scrollbarThumbVertical="@color/scrollbarThumb"
android:layout_gravity="center"
android:scrollbars="vertical" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>

View file

@ -108,29 +108,12 @@
<action
android:id="@+id/openComposeAvatarDetail"
app:destination="@id/ComposeAvatarCustomizationFragment" />
<action
android:id="@+id/openAvatarDetail"
app:destination="@id/avatarCustomizationFragment" />
<action
android:id="@+id/openEquipmentDetail"
app:destination="@id/equipmentDetailFragment" />
<action
android:id="@+id/openComposeAvatarEquipment"
app:destination="@id/composeAvatarEquipmentFragment" />
<action
android:id="@+id/openAvatarEquipment"
app:destination="@id/avatarEquipmentFragment" />
</fragment>
<fragment
android:id="@+id/avatarEquipmentFragment"
android:name="com.habitrpg.android.habitica.ui.fragments.inventory.customization.AvatarEquipmentFragment"
android:label="@string/sidebar_avatar" >
<argument
android:name="type"
app:argType="string" />
<argument
android:name="category"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/composeAvatarEquipmentFragment"
@ -326,17 +309,6 @@
android:name="category"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/avatarCustomizationFragment"
android:name="com.habitrpg.android.habitica.ui.fragments.inventory.customization.AvatarCustomizationFragment"
android:label="@string/sidebar_avatar" >
<argument
android:name="type"
app:argType="string" />
<argument
android:name="category"
app:argType="string" />
</fragment>
<activity
android:id="@+id/prefsActivity"
android:name="com.habitrpg.android.habitica.ui.activities.PrefsActivity"

View file

@ -30,8 +30,11 @@
<string name="enable_notifications">Enable Notifications</string>
<string name="push_notification_system_settings_description">Allow Habitica notifications in the Settings app to receive push notifications</string>
<string name="push_notification_system_settings_reminders">Allow Habitica notifications in the Settings app to receive reminders</string>
<string name="exact_alarm_system_settings_reminders">Habitica does not have the `Alarms &amp; Reminders` permission in the Settings app. Reminders might not appear at the exact time.</string>
<string name="push_notification_system_settings_title">Notifications Disabled</string>
<string name="push_notifications_sum">Set your push notifications settings</string>
<string name="exact_alarm_system_settings_title">`Alarm &amp; Reminders` disabled`</string>
<string name="exact_alarm_system_settings_description">Allow `Alarms &amp; Reminders` in the Settings app to ensure reminders appear at the scheduled time exactly</string>
<string name="preference_push_you_won_challenge">You won a Challenge!</string>
<string name="preference_push_received_a_private_message">Received a Private Message</string>
<string name="preference_push_gifted_gems">Gifted Gems</string>
@ -1539,8 +1542,8 @@
<string name="clear_database">Clear Database</string>
<string name="clear_cache_settings">Clear Cache</string>
<string name="best_deal">Best Deal</string>
<string name="unlock_x_gems_per_month">Unlock %d Gems per month instantly</string>
<string name="unlocks_x_gems_per_month">Unlocks %d Gems per month instantly</string>
<string name="unlock_x_gems_per_month">Unlock %d Gems per month in the Market</string>
<string name="unlocks_x_gems_per_month">Unlocks %d Gems per month in the Market</string>
<string name="x_gems">%d Gems</string>
<string name="two_gems_per_month">Earn +2 Gems every month you\'re subscribed</string>
<string name="two_gems_per_month_gift">Earns +2 Gems every month they\'re subscribed</string>

View file

@ -5,7 +5,6 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.preference.PreferenceManager
import com.habitrpg.android.habitica.data.TaskRepository
import com.habitrpg.android.habitica.extensions.withImmutableFlag
@ -283,26 +282,37 @@ class TaskAlarmManager(
time: Long,
pendingIntent: PendingIntent?,
) {
HLogger.log(LogLevel.INFO, "TaskAlarmManager", "Scheduling for $time")
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
if (pendingIntent == null) {
if (pendingIntent == null || alarmManager == null) {
return
}
val notificationType = AlarmManager.RTC_WAKEUP
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// For SDK >= Android 12, allows batching of reminders
try {
alarmManager?.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pendingIntent)
Log.d(
"TaskAlarmManager",
"setAlarm: Scheduling for $time using setAndAllowWhileIdle",
)
var canScheduleExact = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
canScheduleExact = alarmManager.canScheduleExactAlarms()
}
if (canScheduleExact) {
alarmManager.setExactAndAllowWhileIdle(notificationType, time, pendingIntent)
HLogger.log(LogLevel.DEBUG,
"TaskAlarmManager",
"setAlarm: Scheduling for $time using setExact",
)
} else {
alarmManager.setAndAllowWhileIdle(notificationType, time, pendingIntent)
HLogger.log(LogLevel.DEBUG,
"TaskAlarmManager",
"setAlarm: Scheduling for $time using setAndAllowWhileIdle",
)
}
} catch (ex: Exception) {
when (ex) {
is IllegalStateException, is SecurityException -> {
alarmManager?.setWindow(
AlarmManager.RTC_WAKEUP,
alarmManager.setWindow(
notificationType,
time,
600000,
pendingIntent,
@ -315,7 +325,7 @@ class TaskAlarmManager(
}
}
} else {
alarmManager?.setWindow(AlarmManager.RTC_WAKEUP, time, 600000, pendingIntent)
alarmManager.set(notificationType, time, pendingIntent)
}
}
}

View file

@ -87,9 +87,12 @@ class NotificationPublisher : BroadcastReceiver() {
intent: Intent,
notification: Notification?,
) {
val notificationManager = context?.let { NotificationManagerCompat.from(it) }
val context = context ?: return
val notificationManager = NotificationManagerCompat.from(context)
val id = intent.getIntExtra(NOTIFICATION_ID, 0)
notification?.let { notificationManager?.notify(id, it) }
notification?.let {
notificationManager.safeNotify(context, id, notification)
}
}
private fun buildNotification(

View file

@ -1,5 +1,6 @@
package com.habitrpg.android.habitica.receivers
import android.app.AlarmManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@ -25,7 +26,8 @@ class TaskAlarmBootReceiver : BroadcastReceiver() {
context: Context,
intent: Intent,
) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED
&& intent.action != AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED) {
return
}
MainScope().launch(ExceptionHandler.coroutine()) {

View file

@ -1,12 +1,15 @@
package com.habitrpg.android.habitica.receivers
import android.Manifest
import android.app.Notification
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.RingtoneManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
@ -113,6 +116,13 @@ class TaskReceiver : BroadcastReceiver() {
)
}
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.notify(task.id.hashCode(), notificationBuilder.build())
notificationManager.safeNotify(context, task.id.hashCode(), notificationBuilder.build())
}
}
fun NotificationManagerCompat.safeNotify(context: Context, code: Int, notification: Notification) {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
notify(code, notification)
}

View file

@ -1,19 +1,23 @@
package com.habitrpg.android.habitica.ui.activities
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.ViewParent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
@ -31,6 +35,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import androidx.core.view.children
import androidx.core.view.setPadding
import androidx.drawerlayout.widget.DrawerLayout
@ -179,6 +184,14 @@ open class MainActivity : BaseActivity(), SnackbarActivity {
} else {
viewModel.updateAllowPushNotifications(false)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !viewModel.sharedPreferences.getBoolean("prompted_exact_scheduling", false)) {
val alarmManager = this.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return@registerForActivityResult
if (!alarmManager.canScheduleExactAlarms()) {
val intent =Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
intent.setData(Uri.fromParts("package", applicationContext?.packageName, null));
startActivity(intent)
}
}
}
private val classSelectionResult =

View file

@ -1,15 +1,19 @@
package com.habitrpg.android.habitica.ui.activities
import android.app.Activity
import android.app.AlarmManager
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.provider.Settings
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.view.Menu
@ -891,6 +895,26 @@ class TaskFormActivity : BaseActivity() {
binding.remindersContainer.shouldShowNotifPermission = false
binding.notificationsDisabledLayout.visibility = View.GONE
}
val alarmManager = this.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
var warnAboutInexact = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (alarmManager?.canScheduleExactAlarms() == false) {
warnAboutInexact = true
}
}
if (warnAboutInexact) {
binding.exactAlarmDisabledContainer.visibility = View.VISIBLE
binding.exactAlarmDisabledContainer.setOnClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val intent =Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
intent.setData(Uri.fromParts("package", applicationContext?.packageName, null));
startActivity(intent)
}
}
} else {
binding.exactAlarmDisabledContainer.visibility = View.GONE
}
}
private fun showChallengeDeleteTask() {

View file

@ -1,375 +0,0 @@
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(), item, mainActivity)
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

@ -1,170 +0,0 @@
package com.habitrpg.android.habitica.ui.fragments.inventory.customization
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.data.InventoryRepository
import com.habitrpg.android.habitica.databinding.FragmentRefreshRecyclerviewBinding
import com.habitrpg.android.habitica.helpers.Analytics
import com.habitrpg.android.habitica.models.user.User
import com.habitrpg.android.habitica.ui.adapter.CustomizationEquipmentRecyclerViewAdapter
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.shops.PurchaseDialog
import com.habitrpg.common.habitica.helpers.ExceptionHandler
import com.habitrpg.common.habitica.helpers.launchCatching
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class AvatarEquipmentFragment :
BaseMainFragment<FragmentRefreshRecyclerviewBinding>(),
SwipeRefreshLayout.OnRefreshListener {
@Inject
lateinit var inventoryRepository: InventoryRepository
@Inject
lateinit var userViewModel: MainUserViewModel
override var binding: FragmentRefreshRecyclerviewBinding? = null
override fun createBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): FragmentRefreshRecyclerviewBinding {
return FragmentRefreshRecyclerviewBinding.inflate(inflater, container, false)
}
var type: String? = null
var category: String? = null
private var activeEquipment: String? = null
internal var adapter: CustomizationEquipmentRecyclerViewAdapter =
CustomizationEquipmentRecyclerViewAdapter()
internal var layoutManager: GridLayoutManager = GridLayoutManager(mainActivity, 2)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
showsBackButton = true
adapter.onSelect = { equipment ->
val key =
(if (equipment.key?.isNotBlank() != true) activeEquipment else equipment.key) ?: ""
lifecycleScope.launchCatching {
inventoryRepository.equip(
if (userViewModel.user.value?.preferences?.costume == true) "costume" else "equipped",
key,
)
}
}
adapter.onUnlock = { equipment ->
lifecycleScope.launchCatching {
inventoryRepository.purchaseItem("gear", equipment.key ?: "", 1)
userRepository.retrieveUser(forced = true)
}
}
adapter.onShowPurchaseDialog = { item ->
val dialog = PurchaseDialog(requireContext(), item, mainActivity)
dialog.show()
}
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
showsBackButton = true
super.onViewCreated(view, savedInstanceState)
arguments?.let {
val args = AvatarEquipmentFragmentArgs.fromBundle(it)
type = args.type
if (args.category.isNotEmpty()) {
category = args.category
}
}
binding?.refreshLayout?.setOnRefreshListener(this)
setGridSpanCount(view.width)
val layoutManager = GridLayoutManager(mainActivity, 4)
layoutManager.spanSizeLookup =
object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return if (adapter.getItemViewType(position) == 0) {
layoutManager.spanCount
} else {
1
}
}
}
binding?.recyclerView?.layoutManager = layoutManager
binding?.recyclerView?.addItemDecoration(MarginDecoration(context))
binding?.recyclerView?.adapter = adapter
binding?.recyclerView?.itemAnimator = SafeDefaultItemAnimator()
this.loadEquipment()
userViewModel.user.observe(viewLifecycleOwner) { updateUser(it) }
Analytics.sendNavigationEvent("$type screen")
}
private fun loadEquipment() {
val type = this.type ?: return
lifecycleScope.launchCatching {
inventoryRepository.getEquipmentType(type, category ?: "").collect {
adapter.setEquipment(it)
}
}
}
private fun setGridSpanCount(width: Int) {
val itemWidth = context?.resources?.getDimension(R.dimen.customization_width) ?: 0F
var spanCount = (width / itemWidth).toInt()
if (spanCount == 0) {
spanCount = 1
}
layoutManager.spanCount = spanCount
}
fun updateUser(user: User?) {
this.updateActiveCustomization(user)
this.adapter.gemBalance = user?.gemCount ?: 0
adapter.notifyDataSetChanged()
}
private fun updateActiveCustomization(user: User?) {
if (this.type == null || user?.preferences == null) {
return
}
val outfit =
if (user.preferences?.costume == true) user.items?.gear?.costume else user.items?.gear?.equipped
val activeEquipment =
when (this.type) {
"headAccessory" -> outfit?.headAccessory
"back" -> outfit?.back
"eyewear" -> outfit?.eyeWear
else -> ""
}
if (activeEquipment != null) {
this.activeEquipment = activeEquipment
this.adapter.activeEquipment = activeEquipment
}
}
override fun onRefresh() {
lifecycleScope.launch(ExceptionHandler.coroutine()) {
userRepository.retrieveUser(true, true)
binding?.refreshLayout?.isRefreshing = false
}
}
}

View file

@ -52,7 +52,6 @@ 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.firstOrNull
import javax.inject.Inject
@ -97,14 +96,13 @@ open class AvatarOverviewFragment :
HabiticaTheme {
val avatar by userViewModel.user.observeAsState()
Column {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.background(colorResource(R.color.window_background))) {
ComposableAvatarView(
avatar = avatar,
configManager = appConfigManager,
modifier =
Modifier
.padding(vertical = 24.dp)
.padding(top = 6.dp, bottom = 24.dp)
.size(140.dp, 147.dp),
)
Box(
@ -159,32 +157,19 @@ open class AvatarOverviewFragment :
type: String,
category: String?,
) {
if (appConfigManager.enableCustomizationShop()) {
MainNavigationController.navigate(
AvatarOverviewFragmentDirections.openComposeAvatarDetail(
type,
category ?: "",
),
)
} else {
MainNavigationController.navigate(
AvatarOverviewFragmentDirections.openAvatarDetail(
type,
category ?: "",
),
)
}
MainNavigationController.navigate(
AvatarOverviewFragmentDirections.openComposeAvatarDetail(
type,
category ?: "",
),
)
}
private fun displayAvatarEquipmentFragment(
type: String,
category: String?,
) {
if (appConfigManager.enableCustomizationShop()) {
MainNavigationController.navigate(AvatarOverviewFragmentDirections.openComposeAvatarEquipment(type, category ?: ""))
} else {
MainNavigationController.navigate(AvatarOverviewFragmentDirections.openAvatarEquipment(type, category ?: ""))
}
MainNavigationController.navigate(AvatarOverviewFragmentDirections.openComposeAvatarEquipment(type, category ?: ""))
}
private fun displayEquipmentFragment(

View file

@ -478,7 +478,7 @@ private fun AvatarCustomizationView(
configManager = configManager,
modifier =
Modifier
.padding(vertical = 24.dp)
.padding(top = 6.dp, bottom = 24.dp)
.size(140.dp, 147.dp),
)
Box(

View file

@ -21,7 +21,6 @@ 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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -53,17 +52,13 @@ import androidx.compose.ui.unit.sp
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.data.InventoryRepository
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.inventory.Customization
import com.habitrpg.android.habitica.models.inventory.Equipment
import com.habitrpg.android.habitica.models.user.Gear
import com.habitrpg.android.habitica.models.user.User
import com.habitrpg.android.habitica.ui.adapter.CustomizationEquipmentRecyclerViewAdapter
import com.habitrpg.android.habitica.ui.fragments.BaseMainFragment
import com.habitrpg.android.habitica.ui.theme.colors
import com.habitrpg.android.habitica.ui.viewmodels.MainUserViewModel
@ -229,7 +224,7 @@ private fun AvatarEquipmentView(
configManager = configManager,
modifier =
Modifier
.padding(vertical = 24.dp)
.padding(top = 6.dp, bottom = 24.dp)
.size(140.dp, 147.dp),
)
Box(

View file

@ -15,6 +15,21 @@ import android.view.ViewGroup
import android.widget.AutoCompleteTextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.SearchView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.shape.RoundedCornerShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.view.MenuProvider
import androidx.cursoradapter.widget.SimpleCursorAdapter
import androidx.lifecycle.lifecycleScope
@ -22,12 +37,14 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.data.InventoryRepository
import com.habitrpg.android.habitica.databinding.FragmentRefreshRecyclerviewBinding
import com.habitrpg.android.habitica.databinding.FragmentEquipmentDetailBinding
import com.habitrpg.android.habitica.helpers.AppConfigManager
import com.habitrpg.android.habitica.helpers.ReviewManager
import com.habitrpg.android.habitica.ui.adapter.inventory.EquipmentRecyclerViewAdapter
import com.habitrpg.android.habitica.ui.fragments.BaseMainFragment
import com.habitrpg.android.habitica.ui.helpers.KeyboardUtil
import com.habitrpg.android.habitica.ui.helpers.SafeDefaultItemAnimator
import com.habitrpg.android.habitica.ui.helpers.ToolbarColorHelper
import com.habitrpg.android.habitica.ui.viewmodels.MainUserViewModel
import com.habitrpg.common.habitica.extensions.dpToPx
import com.habitrpg.common.habitica.extensions.observeOnce
@ -35,6 +52,8 @@ import com.habitrpg.common.habitica.helpers.EmptyItem
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 dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@ -44,12 +63,12 @@ import javax.inject.Inject
@AndroidEntryPoint
class EquipmentDetailFragment :
BaseMainFragment<FragmentRefreshRecyclerviewBinding>(),
BaseMainFragment<FragmentEquipmentDetailBinding>(),
SwipeRefreshLayout.OnRefreshListener, MenuProvider {
@Inject
lateinit var inventoryRepository: InventoryRepository
override var binding: FragmentRefreshRecyclerviewBinding? = null
override var binding: FragmentEquipmentDetailBinding? = null
@Inject
lateinit var userViewModel: MainUserViewModel
@ -57,11 +76,14 @@ class EquipmentDetailFragment :
@Inject
lateinit var reviewManager: ReviewManager
@Inject
lateinit var configManager: AppConfigManager
override fun createBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): FragmentRefreshRecyclerviewBinding {
return FragmentRefreshRecyclerviewBinding.inflate(inflater, container, false)
): FragmentEquipmentDetailBinding {
return FragmentEquipmentDetailBinding.inflate(inflater, container, false)
}
var type: String? = null
@ -77,6 +99,9 @@ class EquipmentDetailFragment :
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
showsBackButton = true
hidesToolbar = true
adapter.onEquip = {
lifecycleScope.launchCatching {
inventoryRepository.equipGear(it, isCostume ?: false)
@ -100,7 +125,6 @@ class EquipmentDetailFragment :
view: View,
savedInstanceState: Bundle?,
) {
showsBackButton = true
super.onViewCreated(view, savedInstanceState)
arguments?.let {
@ -120,6 +144,28 @@ class EquipmentDetailFragment :
MainNavigationController.navigate(R.id.marketFragment)
}
binding?.avatarHeader?.setContent {
HabiticaTheme {
val avatar by userViewModel.user.observeAsState()
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.background(colorResource(R.color.window_background))) {
ComposableAvatarView(
avatar = avatar,
configManager = configManager,
modifier =
Modifier
.padding(top = 6.dp, bottom = 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),
)
}
}
}
this.adapter.equippedGear = this.equippedGear
this.adapter.isCostume = this.isCostume
this.adapter.type = this.type
@ -230,6 +276,12 @@ class EquipmentDetailFragment :
return false
}
})
mainActivity?.toolbar?.let {
val color = ContextCompat.getColor(requireContext(), R.color.window_background)
ToolbarColorHelper.colorizeToolbar(it, mainActivity, backgroundColor = color)
requireActivity().window.statusBarColor = color
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {

View file

@ -310,7 +310,7 @@ open class NotificationsViewModel
}
when (data?.destination) {
"equipment" -> navController.navigate(R.id.equipmentOverviewFragment)
"customization" -> navController.navigate(R.id.avatarCustomizationFragment)
"customization" -> navController.navigate(R.id.composeAvatarEquipmentFragment)
"stable" -> navController.navigate(R.id.stableFragment)
"pets" -> navController.navigate(R.id.stableFragment)
"mounts" -> navController.navigate(R.id.stableFragment)

View file

@ -12,106 +12,105 @@ import com.habitrpg.common.habitica.extensions.dpToPx
import com.habitrpg.shared.habitica.models.tasks.TaskType
class ReminderContainer
@JvmOverloads
constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr) {
var taskType = TaskType.DAILY
set(value) {
field = value
for (view in children) {
if (view is ReminderItemFormView) {
view.taskType = taskType
}
@JvmOverloads
constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr) {
var taskType = TaskType.DAILY
set(value) {
field = value
for (view in children) {
if (view is ReminderItemFormView) {
view.taskType = taskType
}
}
var reminders: List<RemindersItem>
get() {
val list = mutableListOf<RemindersItem>()
for (child in children) {
val view = child as? ReminderItemFormView ?: continue
if (view.item.time != null) {
list.add(view.item)
}
}
var reminders: List<RemindersItem>
get() {
val list = mutableListOf<RemindersItem>()
for (child in children) {
val view = child as? ReminderItemFormView ?: continue
if (view.item.time != null) {
list.add(view.item)
}
return list
}
set(value) {
val unAnimatedTransitions = LayoutTransition()
unAnimatedTransitions.disableTransitionType(LayoutTransition.APPEARING)
unAnimatedTransitions.disableTransitionType(LayoutTransition.CHANGING)
unAnimatedTransitions.disableTransitionType(LayoutTransition.DISAPPEARING)
layoutTransition = unAnimatedTransitions
if (childCount > 1) {
for (child in children.take(childCount - 1)) {
removeView(child)
}
return list
}
set(value) {
val unAnimatedTransitions = LayoutTransition()
unAnimatedTransitions.disableTransitionType(LayoutTransition.APPEARING)
unAnimatedTransitions.disableTransitionType(LayoutTransition.CHANGING)
unAnimatedTransitions.disableTransitionType(LayoutTransition.DISAPPEARING)
layoutTransition = unAnimatedTransitions
if (childCount > 1) {
for (child in children.take(childCount - 1)) {
removeView(child)
}
for (item in value) {
addReminderViewAt(childCount - 1, item)
}
val animatedTransitions = LayoutTransition()
layoutTransition = animatedTransitions
}
var firstDayOfWeek: Int? = null
set(value) {
children
.filterIsInstance<ReminderItemFormView>()
.forEach { it.firstDayOfWeek = value }
field = value
for (item in value) {
addReminderViewAt(childCount - 1, item)
}
var showNotifPermission: ((Boolean) -> Unit)? = null
var shouldShowNotifPermission = false
init {
orientation = VERTICAL
addReminderViewAt(0)
val animatedTransitions = LayoutTransition()
layoutTransition = animatedTransitions
}
private fun addReminderViewAt(
index: Int,
item: RemindersItem? = null,
) {
val view = ReminderItemFormView(context)
view.firstDayOfWeek = firstDayOfWeek
view.taskType = taskType
item?.let {
view.item = it
view.isAddButton = false
}
view.valueChangedListener = {
if (isLastChild(view)) {
addReminderViewAt(-1)
view.animDuration = 300
view.isAddButton = false
if (shouldShowNotifPermission) {
showNotifPermission?.invoke(true)
}
}
}
val indexToUse =
if (index < 0) {
childCount - index
} else {
index
}
if (childCount <= indexToUse) {
addView(view)
view.isAddButton = true
} else {
addView(view, indexToUse)
}
val layoutParams = view.layoutParams as? LayoutParams
layoutParams?.updateMargins(bottom = 8.dpToPx(context))
view.layoutParams = layoutParams
var firstDayOfWeek: Int? = null
set(value) {
children
.filterIsInstance<ReminderItemFormView>()
.forEach { it.firstDayOfWeek = value }
field = value
}
private fun isLastChild(view: View): Boolean {
return children.lastOrNull() == view
}
var showNotifPermission: ((Boolean) -> Unit)? = null
var shouldShowNotifPermission = false
init {
orientation = VERTICAL
addReminderViewAt(0)
}
private fun addReminderViewAt(
index: Int,
item: RemindersItem? = null,
) {
val view = ReminderItemFormView(context)
view.firstDayOfWeek = firstDayOfWeek
view.taskType = taskType
item?.let {
view.item = it
view.isAddButton = false
}
view.valueChangedListener = {
if (isLastChild(view)) {
addReminderViewAt(-1)
view.animDuration = 300
view.isAddButton = false
if (shouldShowNotifPermission) {
showNotifPermission?.invoke(true)
}
}
}
val indexToUse =
if (index < 0) {
childCount - index
} else {
index
}
if (childCount <= indexToUse) {
addView(view)
view.isAddButton = true
} else {
addView(view, indexToUse)
}
val layoutParams = view.layoutParams as? LayoutParams
layoutParams?.updateMargins(bottom = 8.dpToPx(context))
view.layoutParams = layoutParams
}
private fun isLastChild(view: View): Boolean {
return children.lastOrNull() == view
}
}

View file

@ -63,14 +63,6 @@ Submit a new Beta Build to Google Play
Deploy a new version to the Google Play
### android upload_to_slack
```sh
[bundle exec] fastlane android upload_to_slack
```
Upload the latest output APK to slack
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.

View file

@ -1,2 +1,2 @@
NAME=4.5.0
CODE=9231
CODE=9261