Refactor MainActivity navigation and AppBars behavior
parent
656405edbc
commit
8b0f221eef
@ -1,151 +0,0 @@
|
||||
package com.google.android.material.appbar
|
||||
|
||||
import android.animation.AnimatorSet
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.FloatRange
|
||||
import com.google.android.material.animation.AnimationUtils
|
||||
import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import org.koitharu.kotatsu.R
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
/**
|
||||
* [AppBarLayout] with our own lift state handler and custom title alpha.
|
||||
*
|
||||
* Inside this package to access some package-private methods.
|
||||
*/
|
||||
class KotatsuAppBarLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppBarLayout(context, attrs) {
|
||||
|
||||
private var lifted = true
|
||||
|
||||
private val toolbar by lazy { findViewById<MaterialToolbar>(R.id.toolbar) }
|
||||
|
||||
@FloatRange(from = 0.0, to = 1.0)
|
||||
var titleTextAlpha = 1F
|
||||
set(value) {
|
||||
field = value
|
||||
titleTextView?.alpha = field
|
||||
}
|
||||
|
||||
private var titleTextView: TextView? = null
|
||||
set(value) {
|
||||
field = value
|
||||
field?.alpha = titleTextAlpha
|
||||
}
|
||||
|
||||
private var animatorSet: AnimatorSet? = null
|
||||
|
||||
private var statusBarForegroundAnimator: ValueAnimator? = null
|
||||
private val offsetListener = OnOffsetChangedListener { appBarLayout, verticalOffset ->
|
||||
// Show status bar foreground when offset
|
||||
val foreground = (appBarLayout?.statusBarForeground as? MaterialShapeDrawable) ?: return@OnOffsetChangedListener
|
||||
val start = foreground.alpha
|
||||
val end = if (verticalOffset != 0) 255 else 0
|
||||
|
||||
statusBarForegroundAnimator?.cancel()
|
||||
if (animatorSet?.isRunning == true) {
|
||||
foreground.alpha = end
|
||||
return@OnOffsetChangedListener
|
||||
}
|
||||
if (start != end) {
|
||||
statusBarForegroundAnimator = ValueAnimator.ofInt(start, end).apply {
|
||||
duration = resources.getInteger(materialR.integer.app_bar_elevation_anim_duration).toLong()
|
||||
interpolator = AnimationUtils.LINEAR_INTERPOLATOR
|
||||
addUpdateListener {
|
||||
foreground.alpha = it.animatedValue as Int
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isTransparentWhenNotLifted = false
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
updateStates()
|
||||
}
|
||||
}
|
||||
|
||||
override fun isLiftOnScroll(): Boolean = false
|
||||
|
||||
override fun isLifted(): Boolean = lifted
|
||||
|
||||
override fun setLifted(lifted: Boolean): Boolean {
|
||||
return if (this.lifted != lifted) {
|
||||
this.lifted = lifted
|
||||
updateStates()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun setLiftedState(lifted: Boolean, force: Boolean): Boolean = false
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
addOnOffsetChangedListener(offsetListener)
|
||||
toolbar.background.alpha = 0 // Use app bar background
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
removeOnOffsetChangedListener(offsetListener)
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
private fun updateStates() {
|
||||
val animators = mutableListOf<ValueAnimator>()
|
||||
|
||||
val fromElevation = elevation
|
||||
val toElevation = if (lifted) {
|
||||
resources.getDimension(materialR.dimen.design_appbar_elevation)
|
||||
} else {
|
||||
0F
|
||||
}
|
||||
if (fromElevation != toElevation) {
|
||||
ValueAnimator.ofFloat(fromElevation, toElevation).apply {
|
||||
addUpdateListener {
|
||||
elevation = it.animatedValue as Float
|
||||
(statusBarForeground as? MaterialShapeDrawable)?.elevation = it.animatedValue as Float
|
||||
}
|
||||
animators.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
val transparent = if (lifted) false else isTransparentWhenNotLifted
|
||||
val fromAlpha = (background as? MaterialShapeDrawable)?.alpha ?: background.alpha
|
||||
val toAlpha = if (transparent) 0 else 255
|
||||
if (fromAlpha != toAlpha) {
|
||||
ValueAnimator.ofInt(fromAlpha, toAlpha).apply {
|
||||
addUpdateListener {
|
||||
val value = it.animatedValue as Int
|
||||
background.alpha = value
|
||||
}
|
||||
animators.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
if (animators.isNotEmpty()) {
|
||||
animatorSet?.cancel()
|
||||
animatorSet = AnimatorSet().apply {
|
||||
duration = resources.getInteger(materialR.integer.app_bar_elevation_anim_duration).toLong()
|
||||
interpolator = AnimationUtils.LINEAR_INTERPOLATOR
|
||||
playTogether(*animators.toTypedArray())
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
statusBarForeground = MaterialShapeDrawable.createWithElevationOverlay(context)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import com.google.android.material.R as materialR
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import org.koitharu.kotatsu.utils.ext.getAnimationDuration
|
||||
|
||||
class StatusBarDimHelper : AppBarLayout.OnOffsetChangedListener {
|
||||
|
||||
private var animator: ValueAnimator? = null
|
||||
private val interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
||||
val foreground = appBarLayout.statusBarForeground ?: return
|
||||
val start = foreground.alpha
|
||||
val collapsed = verticalOffset != 0
|
||||
val end = if (collapsed) 255 else 0
|
||||
animator?.cancel()
|
||||
if (start == end) {
|
||||
animator = null
|
||||
return
|
||||
}
|
||||
animator = ValueAnimator.ofInt(start, end).apply {
|
||||
duration = appBarLayout.context.getAnimationDuration(materialR.integer.app_bar_elevation_anim_duration)
|
||||
interpolator = this@StatusBarDimHelper.interpolator
|
||||
addUpdateListener {
|
||||
foreground.alpha = it.animatedValue as Int
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun attachToAppBar(appBarLayout: AppBarLayout) {
|
||||
appBarLayout.addOnOffsetChangedListener(this)
|
||||
appBarLayout.statusBarForeground =
|
||||
MaterialShapeDrawable.createWithElevationOverlay(appBarLayout.context).apply {
|
||||
alpha = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,134 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.koitharu.kotatsu.base.ui.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.postDelayed
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.FadingSnackbarLayoutBinding
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
private const val SHORT_DURATION_MS = 1_500L
|
||||
private const val LONG_DURATION_MS = 2_750L
|
||||
|
||||
/**
|
||||
* A custom snackbar implementation allowing more control over placement and entry/exit animations.
|
||||
*
|
||||
* Xtimms: Well, my sufferings over the Snackbar in [DetailsActivity] will go away forever... Thanks, Google.
|
||||
*
|
||||
* https://github.com/google/iosched/blob/main/mobile/src/main/java/com/google/samples/apps/iosched/widget/FadingSnackbar.kt
|
||||
*/
|
||||
class FadingSnackbar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val binding = FadingSnackbarLayoutBinding.inflate(LayoutInflater.from(context), this)
|
||||
private val enterDuration = context.resources.getInteger(R.integer.config_defaultAnimTime).toLong()
|
||||
private val exitDuration = context.resources.getInteger(android.R.integer.config_shortAnimTime).toLong()
|
||||
|
||||
init {
|
||||
binding.snackbarLayout.background = createThemedBackground()
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
if (visibility == VISIBLE && alpha == 1f) {
|
||||
animate()
|
||||
.alpha(0f)
|
||||
.withEndAction { visibility = GONE }
|
||||
.duration = exitDuration
|
||||
}
|
||||
}
|
||||
|
||||
fun show(
|
||||
messageText: CharSequence?,
|
||||
@StringRes actionId: Int = 0,
|
||||
duration: Int = Snackbar.LENGTH_SHORT,
|
||||
onActionClick: (FadingSnackbar.() -> Unit)? = null,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
) {
|
||||
binding.snackbarText.text = messageText
|
||||
if (actionId != 0) {
|
||||
with(binding.snackbarAction) {
|
||||
visibility = VISIBLE
|
||||
text = context.getString(actionId)
|
||||
setOnClickListener {
|
||||
onActionClick?.invoke(this@FadingSnackbar) ?: dismiss()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.snackbarAction.visibility = GONE
|
||||
}
|
||||
alpha = 0f
|
||||
visibility = VISIBLE
|
||||
animate()
|
||||
.alpha(1f)
|
||||
.duration = enterDuration
|
||||
if (duration == Snackbar.LENGTH_INDEFINITE) {
|
||||
return
|
||||
}
|
||||
val durationMs = enterDuration + if (duration == Snackbar.LENGTH_LONG) LONG_DURATION_MS else SHORT_DURATION_MS
|
||||
postDelayed(durationMs) {
|
||||
dismiss()
|
||||
onDismiss?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createThemedBackground(): Drawable {
|
||||
val backgroundColor = MaterialColors.layer(this, materialR.attr.colorSurface, materialR.attr.colorOnSurface, 1f)
|
||||
val shapeAppearanceModel = ShapeAppearanceModel.builder(
|
||||
context,
|
||||
materialR.style.ShapeAppearance_Material3_Corner_ExtraSmall,
|
||||
0
|
||||
).build()
|
||||
val background = createMaterialShapeDrawableBackground(
|
||||
backgroundColor,
|
||||
shapeAppearanceModel,
|
||||
)
|
||||
val backgroundTint = context.getThemeColorStateList(materialR.attr.colorSurfaceInverse)
|
||||
return if (backgroundTint != null) {
|
||||
val wrappedDrawable = DrawableCompat.wrap(background)
|
||||
DrawableCompat.setTintList(wrappedDrawable, backgroundTint)
|
||||
wrappedDrawable
|
||||
} else {
|
||||
DrawableCompat.wrap(background)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMaterialShapeDrawableBackground(
|
||||
@ColorInt backgroundColor: Int,
|
||||
shapeAppearanceModel: ShapeAppearanceModel,
|
||||
): MaterialShapeDrawable {
|
||||
val background = MaterialShapeDrawable(shapeAppearanceModel)
|
||||
background.fillColor = ColorStateList.valueOf(backgroundColor)
|
||||
return background
|
||||
}
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
package org.koitharu.kotatsu.base.ui.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.customview.view.AbsSavedState
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import org.koitharu.kotatsu.utils.ext.findChild
|
||||
|
||||
class KotatsuCoordinatorLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = androidx.coordinatorlayout.R.attr.coordinatorLayoutStyle
|
||||
) : CoordinatorLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var appBarLayout: AppBarLayout? = null
|
||||
|
||||
/**
|
||||
* If true, [AppBarLayout] child will be lifted on nested scroll.
|
||||
*/
|
||||
var isLiftAppBarOnScroll = true
|
||||
|
||||
/**
|
||||
* Internal check
|
||||
*/
|
||||
private val canLiftAppBarOnScroll
|
||||
get() = isLiftAppBarOnScroll
|
||||
|
||||
override fun onNestedScroll(
|
||||
target: View,
|
||||
dxConsumed: Int,
|
||||
dyConsumed: Int,
|
||||
dxUnconsumed: Int,
|
||||
dyUnconsumed: Int,
|
||||
type: Int,
|
||||
consumed: IntArray
|
||||
) {
|
||||
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed)
|
||||
if (canLiftAppBarOnScroll) {
|
||||
appBarLayout?.isLifted = dyConsumed != 0 || dyUnconsumed >= 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
appBarLayout = findChild()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
appBarLayout = null
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable? {
|
||||
val superState = super.onSaveInstanceState()
|
||||
return if (superState != null) {
|
||||
SavedState(superState).also {
|
||||
it.appBarLifted = appBarLayout?.isLifted ?: false
|
||||
}
|
||||
} else {
|
||||
superState
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||
if (state is SavedState) {
|
||||
super.onRestoreInstanceState(state.superState)
|
||||
doOnLayout {
|
||||
appBarLayout?.isLifted = state.appBarLifted
|
||||
}
|
||||
} else {
|
||||
super.onRestoreInstanceState(state)
|
||||
}
|
||||
}
|
||||
|
||||
internal class SavedState : AbsSavedState {
|
||||
var appBarLifted = false
|
||||
|
||||
constructor(superState: Parcelable) : super(superState)
|
||||
|
||||
constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) {
|
||||
appBarLifted = source.readByte().toInt() == 1
|
||||
}
|
||||
|
||||
override fun writeToParcel(out: Parcel, flags: Int) {
|
||||
super.writeToParcel(out, flags)
|
||||
out.writeByte((if (appBarLifted) 1 else 0).toByte())
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val CREATOR: Parcelable.ClassLoaderCreator<SavedState> = object : Parcelable.ClassLoaderCreator<SavedState> {
|
||||
override fun createFromParcel(source: Parcel, loader: ClassLoader): SavedState {
|
||||
return SavedState(source, loader)
|
||||
}
|
||||
|
||||
override fun createFromParcel(source: Parcel): SavedState {
|
||||
return SavedState(source, null)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<SavedState> {
|
||||
return newArray(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
package org.koitharu.kotatsu.base.ui.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
|
||||
class SquareLayout @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
public override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, widthMeasureSpec)
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
package org.koitharu.kotatsu.main.ui
|
||||
|
||||
import org.koitharu.kotatsu.base.ui.widgets.KotatsuBottomNavigationView
|
||||
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
|
||||
|
||||
interface BottomNavOwner {
|
||||
|
||||
val bottomNav: KotatsuBottomNavigationView?
|
||||
val bottomNav: SlidingBottomNavigationView?
|
||||
}
|
||||
|
||||
@ -0,0 +1,102 @@
|
||||
package org.koitharu.kotatsu.main.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import com.google.android.material.navigation.NavigationBarView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.explore.ui.ExploreFragment
|
||||
import org.koitharu.kotatsu.library.ui.LibraryFragment
|
||||
import org.koitharu.kotatsu.settings.tools.ToolsFragment
|
||||
import org.koitharu.kotatsu.tracker.ui.FeedFragment
|
||||
import java.util.*
|
||||
|
||||
private const val TAG_PRIMARY = "primary"
|
||||
|
||||
class MainNavigationDelegate(
|
||||
private val navBar: NavigationBarView,
|
||||
private val fragmentManager: FragmentManager,
|
||||
) : NavigationBarView.OnItemSelectedListener, NavigationBarView.OnItemReselectedListener {
|
||||
|
||||
private val listeners = LinkedList<OnFragmentChangedListener>()
|
||||
|
||||
val primaryFragment: Fragment?
|
||||
get() = fragmentManager.findFragmentByTag(TAG_PRIMARY)
|
||||
|
||||
init {
|
||||
navBar.setOnItemSelectedListener(this)
|
||||
navBar.setOnItemReselectedListener(this)
|
||||
}
|
||||
|
||||
override fun onNavigationItemSelected(item: MenuItem): Boolean {
|
||||
return onNavigationItemSelected(item.itemId)
|
||||
}
|
||||
|
||||
override fun onNavigationItemReselected(item: MenuItem) {
|
||||
val fragment = fragmentManager.findFragmentByTag(TAG_PRIMARY) as? RecyclerViewOwner ?: return
|
||||
val recyclerView = fragment.recyclerView
|
||||
recyclerView.smoothScrollToPosition(0)
|
||||
}
|
||||
|
||||
fun onCreate(savedInstanceState: Bundle?) {
|
||||
primaryFragment?.let {
|
||||
onFragmentChanged(it, fromUser = false)
|
||||
} ?: onNavigationItemSelected(navBar.selectedItemId)
|
||||
}
|
||||
|
||||
fun setCounter(@IdRes id: Int, counter: Int) {
|
||||
if (counter == 0) {
|
||||
navBar.getBadge(id)?.isVisible = false
|
||||
} else {
|
||||
val badge = navBar.getOrCreateBadge(id)
|
||||
if (counter < 0) {
|
||||
badge.clearNumber()
|
||||
} else {
|
||||
badge.number = counter
|
||||
}
|
||||
badge.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
fun addOnFragmentChangedListener(listener: OnFragmentChangedListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeOnFragmentChangedListener(listener: OnFragmentChangedListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean {
|
||||
setPrimaryFragment(
|
||||
when (itemId) {
|
||||
R.id.nav_library -> LibraryFragment.newInstance()
|
||||
R.id.nav_explore -> ExploreFragment.newInstance()
|
||||
R.id.nav_feed -> FeedFragment.newInstance()
|
||||
R.id.nav_tools -> ToolsFragment.newInstance()
|
||||
else -> return false
|
||||
},
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setPrimaryFragment(fragment: Fragment) {
|
||||
fragmentManager.beginTransaction()
|
||||
.replace(R.id.container, fragment, TAG_PRIMARY)
|
||||
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
|
||||
.commit()
|
||||
onFragmentChanged(fragment, fromUser = true)
|
||||
}
|
||||
|
||||
private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {
|
||||
listeners.forEach { it.onFragmentChanged(fragment, fromUser) }
|
||||
}
|
||||
|
||||
interface OnFragmentChangedListener {
|
||||
|
||||
fun onFragmentChanged(fragment: Fragment, fromUser: Boolean)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue