mirror of https://github.com/tasks/tasks
Tag picker compose (#2849)
TagPickerActivity refactoring to Compose 1. state of the SearchBar moved to the viewModel 2. viewModel used as parameter to @Composables instead of number of separate ones 3. Import of TagPickerActivity is replaced by TagPickerActivityCompose through the projectpull/2850/head
parent
782f4d6d7c
commit
e6e275834a
@ -1,108 +0,0 @@
|
|||||||
package org.tasks.tags
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.widget.EditText
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.core.widget.addTextChangedListener
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.DefaultItemAnimator
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.tasks.R
|
|
||||||
import org.tasks.Strings.isNullOrEmpty
|
|
||||||
import org.tasks.billing.Inventory
|
|
||||||
import org.tasks.data.TagData
|
|
||||||
import org.tasks.databinding.ActivityTagPickerBinding
|
|
||||||
import org.tasks.extensions.addBackPressedCallback
|
|
||||||
import org.tasks.injection.ThemedInjectingAppCompatActivity
|
|
||||||
import org.tasks.themes.ColorProvider
|
|
||||||
import org.tasks.themes.Theme
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class TagPickerActivity : ThemedInjectingAppCompatActivity() {
|
|
||||||
@Inject lateinit var theme: Theme
|
|
||||||
@Inject lateinit var inventory: Inventory
|
|
||||||
@Inject lateinit var colorProvider: ColorProvider
|
|
||||||
|
|
||||||
private val viewModel: TagPickerViewModel by viewModels()
|
|
||||||
private var taskIds: ArrayList<Long>? = null
|
|
||||||
private lateinit var editText: EditText
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
val intent = intent
|
|
||||||
taskIds = intent.getSerializableExtra(EXTRA_TASKS) as ArrayList<Long>?
|
|
||||||
if (savedInstanceState == null) {
|
|
||||||
intent.getParcelableArrayListExtra<TagData>(EXTRA_SELECTED)?.let {
|
|
||||||
viewModel.setSelected(
|
|
||||||
it,
|
|
||||||
intent.getParcelableArrayListExtra(EXTRA_PARTIALLY_SELECTED)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val binding = ActivityTagPickerBinding.inflate(layoutInflater)
|
|
||||||
editText = binding.searchInput.apply {
|
|
||||||
addTextChangedListener(
|
|
||||||
onTextChanged = { text, _, _, _ -> onSearch(text) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
setContentView(binding.root)
|
|
||||||
val toolbar = binding.toolbar
|
|
||||||
toolbar.setNavigationIcon(R.drawable.ic_outline_arrow_back_24px)
|
|
||||||
toolbar.setNavigationOnClickListener { onBackPressed() }
|
|
||||||
val themeColor = theme.themeColor
|
|
||||||
themeColor.applyToNavigationBar(this)
|
|
||||||
val recyclerAdapter = TagRecyclerAdapter(this, viewModel, inventory, colorProvider) { tagData, vh ->
|
|
||||||
onToggle(tagData, vh)
|
|
||||||
}
|
|
||||||
val recyclerView = binding.recyclerView
|
|
||||||
recyclerView.adapter = recyclerAdapter
|
|
||||||
(recyclerView.itemAnimator as DefaultItemAnimator?)!!.supportsChangeAnimations = false
|
|
||||||
recyclerView.layoutManager = LinearLayoutManager(this)
|
|
||||||
viewModel.observe(this) { recyclerAdapter.submitList(it) }
|
|
||||||
editText.setText(viewModel.text)
|
|
||||||
|
|
||||||
addBackPressedCallback {
|
|
||||||
if (isNullOrEmpty(viewModel.text)) {
|
|
||||||
val data = Intent()
|
|
||||||
data.putExtra(EXTRA_TASKS, taskIds)
|
|
||||||
data.putParcelableArrayListExtra(
|
|
||||||
EXTRA_PARTIALLY_SELECTED,
|
|
||||||
viewModel.getPartiallySelected()
|
|
||||||
)
|
|
||||||
data.putParcelableArrayListExtra(EXTRA_SELECTED, viewModel.getSelected())
|
|
||||||
setResult(Activity.RESULT_OK, data)
|
|
||||||
finish()
|
|
||||||
} else {
|
|
||||||
clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onToggle(tagData: TagData, vh: TagPickerViewHolder) = lifecycleScope.launch {
|
|
||||||
val newTag = tagData.id == null
|
|
||||||
val newState = viewModel.toggle(tagData, vh.isChecked || newTag)
|
|
||||||
vh.updateCheckbox(newState)
|
|
||||||
if (newTag) {
|
|
||||||
clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onSearch(text: CharSequence?) {
|
|
||||||
viewModel.search(text?.toString() ?: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clear() {
|
|
||||||
editText.setText("")
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val EXTRA_SELECTED = "extra_tags"
|
|
||||||
const val EXTRA_PARTIALLY_SELECTED = "extra_partial"
|
|
||||||
const val EXTRA_TASKS = "extra_tasks"
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,292 @@
|
|||||||
|
package org.tasks.tags
|
||||||
|
|
||||||
|
/*
|
||||||
|
* TagPickerActivityCompose is a replacement for TagPickerActivity reimplemented
|
||||||
|
* using JetPack Compose framework.
|
||||||
|
*
|
||||||
|
* The modification eliminates TagRecycleAdapter.
|
||||||
|
* TriStateCheckbox from Compose is used instead of CheckBoxTriState.
|
||||||
|
* Source code for TagRecycleAdapter and TagPickerActivity were deleted because they became incompatible
|
||||||
|
* with modified TagPickerViewModel, but were not excluded from build due to the hilt logic.
|
||||||
|
*
|
||||||
|
* There is a tag "FeatureReady" in the git commits log which marks the state when the main logic was
|
||||||
|
* already implemented via Compose but but viewModel was modified with backward compatibility so to
|
||||||
|
* switch back to View implementation its enough to find lines like this
|
||||||
|
* //val intent = Intent(context, TagPickerActivity::class.java)
|
||||||
|
* val intent = Intent(context, TagPickerActivityCompose::class.java)
|
||||||
|
* in TaskListFragment.kt and TagsControlSet.kt and move comment mark to another line.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.TextField
|
||||||
|
import androidx.compose.material.TextFieldDefaults
|
||||||
|
import androidx.compose.material.TriStateCheckbox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.vectorResource
|
||||||
|
import androidx.compose.ui.state.ToggleableState
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.google.android.material.composethemeadapter.MdcTheme
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.tasks.R
|
||||||
|
import org.tasks.Strings
|
||||||
|
import org.tasks.billing.Inventory
|
||||||
|
import org.tasks.data.TagData
|
||||||
|
import org.tasks.extensions.addBackPressedCallback
|
||||||
|
import org.tasks.injection.ThemedInjectingAppCompatActivity
|
||||||
|
import org.tasks.themes.ColorProvider
|
||||||
|
import org.tasks.themes.CustomIcons
|
||||||
|
import org.tasks.themes.Theme
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class TagPickerActivityCompose : ThemedInjectingAppCompatActivity() {
|
||||||
|
@Inject lateinit var theme: Theme
|
||||||
|
@Inject lateinit var inventory: Inventory
|
||||||
|
@Inject lateinit var colorProvider: ColorProvider
|
||||||
|
|
||||||
|
|
||||||
|
private val viewModel: TagPickerViewModel by viewModels()
|
||||||
|
private var taskIds: ArrayList<Long>? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val intent = intent
|
||||||
|
taskIds = intent.getSerializableExtra(EXTRA_TASKS) as ArrayList<Long>?
|
||||||
|
if (savedInstanceState == null) {
|
||||||
|
val selected = intent.getParcelableArrayListExtra<TagData>(EXTRA_SELECTED)
|
||||||
|
if ( selected != null ) {
|
||||||
|
viewModel.setSelected(
|
||||||
|
selected, intent.getParcelableArrayListExtra<TagData>(EXTRA_PARTIALLY_SELECTED)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addBackPressedCallback { handleBackPressed() }
|
||||||
|
|
||||||
|
viewModel.search("")
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
MdcTheme {
|
||||||
|
TagPicker(
|
||||||
|
viewModel,
|
||||||
|
onBackClicked = { handleBackPressed() },
|
||||||
|
getTagIcon = { tagData -> getIcon(tagData) },
|
||||||
|
getTagColor = { tagData -> getColor(tagData) }
|
||||||
|
)
|
||||||
|
} /* setContent */
|
||||||
|
}
|
||||||
|
} /* onCreate */
|
||||||
|
|
||||||
|
private fun handleBackPressed() {
|
||||||
|
if (Strings.isNullOrEmpty(viewModel.searchText.value)) {
|
||||||
|
val data = Intent()
|
||||||
|
data.putExtra(EXTRA_TASKS, taskIds)
|
||||||
|
data.putParcelableArrayListExtra(EXTRA_PARTIALLY_SELECTED, viewModel.getPartiallySelected())
|
||||||
|
data.putParcelableArrayListExtra(EXTRA_SELECTED, viewModel.getSelected())
|
||||||
|
setResult(Activity.RESULT_OK, data)
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
viewModel.search("")
|
||||||
|
}
|
||||||
|
} /* handleBackPressed */
|
||||||
|
|
||||||
|
private fun getColor(tagData: TagData): Color {
|
||||||
|
if (tagData.getColor() != 0) {
|
||||||
|
val themeColor = colorProvider.getThemeColor(tagData.getColor()!!, true)
|
||||||
|
if (inventory.purchasedThemes() || themeColor.isFree) {
|
||||||
|
return Color(themeColor.primaryColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Color(getColor(R.color.icon_tint_with_alpha))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getIcon(tagData: TagData): Int
|
||||||
|
{
|
||||||
|
val iconIndex = tagData.getIcon()
|
||||||
|
var iconResource = R.drawable.ic_outline_label_24px
|
||||||
|
if ( (iconIndex != null) && (iconIndex < 1000 || inventory.hasPro) ) {
|
||||||
|
iconResource = CustomIcons.getIconResId(iconIndex) ?: R.drawable.ic_outline_label_24px
|
||||||
|
}
|
||||||
|
return iconResource
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copy of the TagPickerActivity's companion object */
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_SELECTED = "extra_tags"
|
||||||
|
const val EXTRA_PARTIALLY_SELECTED = "extra_partial"
|
||||||
|
const val EXTRA_TASKS = "extra_tasks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TagPicker(
|
||||||
|
viewModel: TagPickerViewModel,
|
||||||
|
onBackClicked: () -> Unit,
|
||||||
|
getTagIcon: (TagData) -> Int,
|
||||||
|
getTagColor: (TagData) -> Color
|
||||||
|
) {
|
||||||
|
Box ( modifier = Modifier.fillMaxSize() )
|
||||||
|
{
|
||||||
|
Column (modifier = Modifier.padding(horizontal = 12.dp)) {
|
||||||
|
Box( modifier = Modifier.fillMaxWidth() ) {
|
||||||
|
SearchBar(viewModel, onBackClicked)
|
||||||
|
}
|
||||||
|
Box (
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
PickerBox(viewModel, viewModel.tagsList.observeAsState(initial = emptyList()), getTagIcon, getTagColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SearchBar(
|
||||||
|
viewModel: TagPickerViewModel,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val searchPattern = remember { viewModel.searchText }
|
||||||
|
val invitation = LocalContext.current.getString(R.string.enter_tag_name)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
ImageVector.vectorResource(id = R.drawable.ic_outline_arrow_back_24px),
|
||||||
|
"Done",
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(6.dp)
|
||||||
|
.clickable { onBack() }
|
||||||
|
)
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
value = searchPattern.value,
|
||||||
|
onValueChange = { viewModel.search(it) },
|
||||||
|
placeholder = { Text(invitation) },
|
||||||
|
colors = TextFieldDefaults.textFieldColors(
|
||||||
|
textColor = MaterialTheme.colors.onBackground,
|
||||||
|
backgroundColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(start = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} /* SearchBar */
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PickerBox (
|
||||||
|
viewModel: TagPickerViewModel,
|
||||||
|
tags: State<List<TagData>>,
|
||||||
|
getTagIcon: (TagData) -> Int = { R.drawable.ic_outline_label_24px },
|
||||||
|
getTagColor: (TagData) -> Color = { Color.Gray }
|
||||||
|
) {
|
||||||
|
val onClick: (TagData) -> Unit = {
|
||||||
|
viewModel.viewModelScope.launch {
|
||||||
|
viewModel.toggle(it, viewModel.getState(it) != ToggleableState.On) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val newItem: (String) -> Unit = {
|
||||||
|
viewModel.viewModelScope.launch { viewModel.createNew(it); viewModel.search("") }
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn {
|
||||||
|
if (viewModel.tagToCreate.value != "") {
|
||||||
|
item(key = -1) {
|
||||||
|
val text = LocalContext.current.getString(R.string.new_tag) + " \"${viewModel.tagToCreate.value}\""
|
||||||
|
TagRow(
|
||||||
|
icon = ImageVector.vectorResource(R.drawable.ic_outline_add_24px),
|
||||||
|
iconColor = Color(LocalContext.current.getColor(R.color.icon_tint_with_alpha)),
|
||||||
|
text = text,
|
||||||
|
onClick = { newItem(viewModel.searchText.value) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items( tags.value, key = { tag -> tag.id!! } )
|
||||||
|
{
|
||||||
|
val checked = remember { mutableStateOf ( viewModel.getState(it) ) }
|
||||||
|
val clickChecked: () -> Unit = { onClick(it); checked.value = viewModel.getState(it) }
|
||||||
|
TagRow(
|
||||||
|
icon = ImageVector.vectorResource(getTagIcon(it)),
|
||||||
|
iconColor = getTagColor(it),
|
||||||
|
text = it.name!!,
|
||||||
|
onClick = clickChecked
|
||||||
|
) {
|
||||||
|
TriStateCheckbox(
|
||||||
|
modifier = Modifier.padding(6.dp),
|
||||||
|
state = checked.value,
|
||||||
|
onClick = clickChecked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} /* PickerBox */
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TagRow (
|
||||||
|
icon: ImageVector,
|
||||||
|
iconColor: Color,
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
checkBox: @Composable RowScope.() -> Unit = {}
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().clickable{ onClick() },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = "",
|
||||||
|
modifier = Modifier.padding(6.dp),
|
||||||
|
tint = iconColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
modifier = Modifier.weight(1f).padding(horizontal = 24.dp)
|
||||||
|
)
|
||||||
|
checkBox()
|
||||||
|
}
|
||||||
|
} /* TagRow */
|
||||||
|
|
||||||
|
/*
|
||||||
|
internal fun genTestTags(): List<TagData>
|
||||||
|
{
|
||||||
|
var idcc: Long = 1
|
||||||
|
return "alpha beta gamma delta kappa theta alfa1 beta1 gamma1 delta1 kappa1 theta1"
|
||||||
|
.split(" ")
|
||||||
|
.map { name -> TagData(name).also{ it.id = idcc++ } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview(showBackground = true, backgroundColor = 0xffffff)
|
||||||
|
internal fun PickerBoxPreview() {
|
||||||
|
val list = remember { mutableStateOf( genTestTags() ) }
|
||||||
|
PickerBox(list, getTagColor = { Color.Green })
|
||||||
|
}
|
||||||
|
*/
|
@ -1,55 +0,0 @@
|
|||||||
package org.tasks.tags
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.recyclerview.widget.AsyncListDiffer
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.tasks.R
|
|
||||||
import org.tasks.billing.Inventory
|
|
||||||
import org.tasks.data.TagData
|
|
||||||
import org.tasks.databinding.RowTagPickerBinding
|
|
||||||
import org.tasks.themes.ColorProvider
|
|
||||||
import org.tasks.themes.CustomIcons.getIconResId
|
|
||||||
|
|
||||||
internal class TagRecyclerAdapter(
|
|
||||||
private val context: Context,
|
|
||||||
private val viewModel: TagPickerViewModel,
|
|
||||||
private val inventory: Inventory,
|
|
||||||
private val colorProvider: ColorProvider,
|
|
||||||
private val callback: (TagData, TagPickerViewHolder) -> Unit
|
|
||||||
) : RecyclerView.Adapter<TagPickerViewHolder>() {
|
|
||||||
|
|
||||||
private val differ: AsyncListDiffer<TagData> = AsyncListDiffer(this, TagDiffCallback())
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
|
||||||
TagPickerViewHolder(
|
|
||||||
context,
|
|
||||||
RowTagPickerBinding.inflate(LayoutInflater.from(context), parent, false),
|
|
||||||
callback
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: TagPickerViewHolder, position: Int) {
|
|
||||||
val tagData = differ.currentList[position]
|
|
||||||
holder.bind(tagData, getColor(tagData), getIcon(tagData), viewModel.getState(tagData))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int = differ.currentList.size
|
|
||||||
|
|
||||||
private fun getColor(tagData: TagData): Int {
|
|
||||||
if (tagData.getColor() != 0) {
|
|
||||||
val themeColor = colorProvider.getThemeColor(tagData.getColor()!!, true)
|
|
||||||
if (inventory.purchasedThemes() || themeColor.isFree) {
|
|
||||||
return themeColor.primaryColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return context.getColor(R.color.icon_tint_with_alpha)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getIcon(tagData: TagData): Int? =
|
|
||||||
tagData.getIcon()?.takeIf { it < 1000 || inventory.hasPro }?.let { getIconResId(it) }
|
|
||||||
|
|
||||||
fun submitList(tagData: List<TagData>?) {
|
|
||||||
differ.submitList(tagData)
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue