Fix list items reorder
This commit is contained in:
@@ -95,7 +95,6 @@ dependencies {
|
||||
implementation 'androidx.activity:activity-ktx:1.8.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
|
||||
// implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
@@ -126,8 +125,8 @@ dependencies {
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||
|
||||
implementation 'com.google.dagger:hilt-android:2.48.1'
|
||||
kapt 'com.google.dagger:hilt-compiler:2.48.1'
|
||||
implementation 'com.google.dagger:hilt-android:2.49'
|
||||
kapt 'com.google.dagger:hilt-compiler:2.49'
|
||||
implementation 'androidx.hilt:hilt-work:1.1.0'
|
||||
kapt 'androidx.hilt:hilt-compiler:1.1.0'
|
||||
|
||||
@@ -156,6 +155,6 @@ dependencies {
|
||||
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
|
||||
|
||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1'
|
||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1'
|
||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.49'
|
||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.49'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
|
||||
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import java.util.Collections
|
||||
import java.util.LinkedList
|
||||
|
||||
open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> {
|
||||
|
||||
private val listListeners = LinkedList<ListListener<T>>()
|
||||
|
||||
override suspend fun emit(value: List<T>?) {
|
||||
val oldList = items.orEmpty()
|
||||
val newList = value.orEmpty()
|
||||
val diffResult = withContext(Dispatchers.Default) {
|
||||
val diffCallback = DiffCallback(oldList, newList)
|
||||
DiffUtil.calculateDiff(diffCallback)
|
||||
}
|
||||
super.setItems(newList)
|
||||
diffResult.dispatchUpdatesTo(this)
|
||||
listListeners.forEach { it.onCurrentListChanged(oldList, newList) }
|
||||
}
|
||||
|
||||
@Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR)
|
||||
override fun setItems(items: List<T>?) {
|
||||
super.setItems(items)
|
||||
}
|
||||
|
||||
fun reorderItems(oldPos: Int, newPos: Int) {
|
||||
Collections.swap(items ?: return, oldPos, newPos)
|
||||
notifyItemMoved(oldPos, newPos)
|
||||
}
|
||||
|
||||
fun addDelegate(type: ListItemType, delegate: AdapterDelegate<List<T>>): ReorderableListAdapter<T> {
|
||||
delegatesManager.addDelegate(type.ordinal, delegate)
|
||||
return this
|
||||
}
|
||||
|
||||
fun addListListener(listListener: ListListener<T>) {
|
||||
listListeners.add(listListener)
|
||||
}
|
||||
|
||||
fun removeListListener(listListener: ListListener<T>) {
|
||||
listListeners.remove(listListener)
|
||||
}
|
||||
|
||||
protected class DiffCallback<T : ListModel>(
|
||||
val oldList: List<T>,
|
||||
val newList: List<T>,
|
||||
) : DiffUtil.Callback() {
|
||||
|
||||
override fun getOldListSize(): Int = oldList.size
|
||||
|
||||
override fun getNewListSize(): Int = newList.size
|
||||
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
return newItem.areItemsTheSame(oldItem)
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
return newItem == oldItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,11 @@ class FavouriteCategoriesActivity :
|
||||
}
|
||||
val fromPos = viewHolder.bindingAdapterPosition
|
||||
val toPos = target.bindingAdapterPosition
|
||||
return fromPos != toPos && fromPos != RecyclerView.NO_POSITION && toPos != RecyclerView.NO_POSITION
|
||||
if (fromPos == toPos || fromPos == RecyclerView.NO_POSITION || toPos == RecyclerView.NO_POSITION) {
|
||||
return false
|
||||
}
|
||||
adapter.reorderItems(fromPos, toPos)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun canDropOver(
|
||||
@@ -151,25 +155,16 @@ class FavouriteCategoriesActivity :
|
||||
target: RecyclerView.ViewHolder,
|
||||
): Boolean = current.itemViewType == target.itemViewType
|
||||
|
||||
override fun onMoved(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
fromPos: Int,
|
||||
target: RecyclerView.ViewHolder,
|
||||
toPos: Int,
|
||||
x: Int,
|
||||
y: Int,
|
||||
) {
|
||||
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
|
||||
viewModel.reorderCategories(fromPos, toPos)
|
||||
}
|
||||
|
||||
override fun isLongPressDragEnabled(): Boolean = false
|
||||
|
||||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
||||
super.onSelectedChanged(viewHolder, actionState)
|
||||
viewBinding.recyclerView.isNestedScrollingEnabled =
|
||||
actionState == ItemTouchHelper.ACTION_STATE_IDLE
|
||||
viewBinding.recyclerView.isNestedScrollingEnabled = actionState == ItemTouchHelper.ACTION_STATE_IDLE
|
||||
}
|
||||
|
||||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
viewModel.saveOrder(adapter.items ?: return)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.categories
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -19,7 +20,6 @@ import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.parsers.util.move
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -30,17 +30,9 @@ class FavouritesCategoriesViewModel @Inject constructor(
|
||||
|
||||
private var commitJob: Job? = null
|
||||
|
||||
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
||||
|
||||
init {
|
||||
launchJob(Dispatchers.Default) {
|
||||
repository.observeCategoriesWithCovers()
|
||||
.collectLatest {
|
||||
commitJob?.join()
|
||||
updateContent(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
val content = repository.observeCategoriesWithCovers()
|
||||
.map { it.toUiList() }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||
|
||||
fun deleteCategories(ids: Set<Long>) {
|
||||
launchJob(Dispatchers.Default) {
|
||||
@@ -54,11 +46,17 @@ class FavouritesCategoriesViewModel @Inject constructor(
|
||||
|
||||
fun isEmpty(): Boolean = content.value.none { it is CategoryListModel }
|
||||
|
||||
fun reorderCategories(oldPos: Int, newPos: Int) {
|
||||
val snapshot = content.requireValue().toMutableList()
|
||||
snapshot.move(oldPos, newPos)
|
||||
content.value = snapshot
|
||||
commit(snapshot)
|
||||
fun saveOrder(snapshot: List<ListModel>) {
|
||||
val prevJob = commitJob
|
||||
commitJob = launchJob {
|
||||
prevJob?.cancelAndJoin()
|
||||
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
|
||||
(it as? CategoryListModel)?.category?.id
|
||||
}
|
||||
if (ids.isNotEmpty()) {
|
||||
repository.reorderCategories(ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setIsVisible(ids: Set<Long>, isVisible: Boolean) {
|
||||
@@ -76,36 +74,21 @@ class FavouritesCategoriesViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun commit(snapshot: List<ListModel>) {
|
||||
val prevJob = commitJob
|
||||
commitJob = launchJob {
|
||||
prevJob?.cancelAndJoin()
|
||||
delay(500)
|
||||
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
|
||||
(it as? CategoryListModel)?.category?.id
|
||||
}
|
||||
repository.reorderCategories(ids)
|
||||
yield()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateContent(categories: Map<FavouriteCategory, List<Cover>>) {
|
||||
content.value = categories.map { (category, covers) ->
|
||||
CategoryListModel(
|
||||
mangaCount = covers.size,
|
||||
covers = covers.take(3),
|
||||
category = category,
|
||||
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
|
||||
)
|
||||
}.ifEmpty {
|
||||
listOf(
|
||||
EmptyState(
|
||||
icon = R.drawable.ic_empty_favourites,
|
||||
textPrimary = R.string.text_empty_holder_primary,
|
||||
textSecondary = R.string.empty_favourite_categories,
|
||||
actionStringRes = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
private fun Map<FavouriteCategory, List<Cover>>.toUiList(): List<ListModel> = map { (category, covers) ->
|
||||
CategoryListModel(
|
||||
mangaCount = covers.size,
|
||||
covers = covers.take(3),
|
||||
category = category,
|
||||
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
|
||||
)
|
||||
}.ifEmpty {
|
||||
listOf(
|
||||
EmptyState(
|
||||
icon = R.drawable.ic_empty_favourites,
|
||||
textPrimary = R.string.text_empty_holder_primary,
|
||||
textSecondary = R.string.empty_favourite_categories,
|
||||
actionStringRes = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.ReorderableListAdapter
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
||||
@@ -15,7 +15,7 @@ class CategoriesAdapter(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
onItemClickListener: FavouriteCategoriesListListener,
|
||||
listListener: ListStateHolderListener,
|
||||
) : BaseListAdapter<ListModel>() {
|
||||
) : ReorderableListAdapter<ListModel>() {
|
||||
|
||||
init {
|
||||
addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener))
|
||||
|
||||
@@ -2,14 +2,14 @@ package org.koitharu.kotatsu.settings.sources.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.ReorderableListAdapter
|
||||
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
|
||||
|
||||
class SourceConfigAdapter(
|
||||
listener: SourceConfigListener,
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) : BaseListAdapter<SourceConfigItem>() {
|
||||
) : ReorderableListAdapter<SourceConfigItem>() {
|
||||
|
||||
init {
|
||||
with(delegatesManager) {
|
||||
|
||||
@@ -54,6 +54,7 @@ class SourcesManageFragment :
|
||||
lateinit var shortcutManager: AppShortcutManager
|
||||
|
||||
private var reorderHelper: ItemTouchHelper? = null
|
||||
private var sourcesAdapter: SourceConfigAdapter? = null
|
||||
private val viewModel by viewModels<SourcesManageViewModel>()
|
||||
|
||||
override val recyclerView: RecyclerView
|
||||
@@ -69,7 +70,7 @@ class SourcesManageFragment :
|
||||
savedInstanceState: Bundle?,
|
||||
) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner)
|
||||
sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner)
|
||||
with(binding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
adapter = sourcesAdapter
|
||||
@@ -77,7 +78,7 @@ class SourcesManageFragment :
|
||||
it.attachToRecyclerView(this)
|
||||
}
|
||||
}
|
||||
viewModel.content.observe(viewLifecycleOwner, sourcesAdapter)
|
||||
viewModel.content.observe(viewLifecycleOwner, checkNotNull(sourcesAdapter))
|
||||
viewModel.onActionDone.observeEvent(
|
||||
viewLifecycleOwner,
|
||||
ReversibleActionObserver(binding.recyclerView),
|
||||
@@ -91,6 +92,7 @@ class SourcesManageFragment :
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
sourcesAdapter = null
|
||||
reorderHelper = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
@@ -204,7 +206,7 @@ class SourcesManageFragment :
|
||||
y: Int,
|
||||
) {
|
||||
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
|
||||
viewModel.reorderSources(fromPos, toPos)
|
||||
sourcesAdapter?.reorderItems(fromPos, toPos)
|
||||
}
|
||||
|
||||
override fun canDropOver(
|
||||
@@ -248,5 +250,10 @@ class SourcesManageFragment :
|
||||
}
|
||||
|
||||
override fun isLongPressDragEnabled() = true
|
||||
|
||||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
viewModel.saveSourcesOrder(sourcesAdapter?.items ?: return)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.yield
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.removeObserverAsync
|
||||
@@ -43,17 +41,19 @@ class SourcesManageViewModel @Inject constructor(
|
||||
database.invalidationTracker.removeObserverAsync(listProducer)
|
||||
}
|
||||
|
||||
fun reorderSources(oldPos: Int, newPos: Int) {
|
||||
val snapshot = content.value.toMutableList()
|
||||
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
|
||||
return
|
||||
fun saveSourcesOrder(snapshot: List<SourceConfigItem>) {
|
||||
val prevJob = commitJob
|
||||
commitJob = launchJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
val newSourcesList = snapshot.mapNotNull { x ->
|
||||
if (x is SourceConfigItem.SourceItem && x.isDraggable) {
|
||||
x.source
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
repository.setPositions(newSourcesList)
|
||||
}
|
||||
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
|
||||
return
|
||||
}
|
||||
snapshot.move(oldPos, newPos)
|
||||
content.value = snapshot
|
||||
commit(snapshot)
|
||||
}
|
||||
|
||||
fun canReorder(oldPos: Int, newPos: Int): Boolean {
|
||||
@@ -72,28 +72,31 @@ class SourcesManageViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun bringToTop(source: MangaSource) {
|
||||
var oldPos = -1
|
||||
var newPos = -1
|
||||
val snapshot = content.value
|
||||
for ((i, x) in snapshot.withIndex()) {
|
||||
if (x !is SourceConfigItem.SourceItem) {
|
||||
continue
|
||||
launchJob(Dispatchers.Default) {
|
||||
var oldPos = -1
|
||||
var newPos = -1
|
||||
for ((i, x) in snapshot.withIndex()) {
|
||||
if (x !is SourceConfigItem.SourceItem) {
|
||||
continue
|
||||
}
|
||||
if (newPos == -1) {
|
||||
newPos = i
|
||||
}
|
||||
if (x.source == source) {
|
||||
oldPos = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (newPos == -1) {
|
||||
newPos = i
|
||||
@Suppress("KotlinConstantConditions")
|
||||
if (oldPos != -1 && newPos != -1) {
|
||||
reorderSources(oldPos, newPos)
|
||||
val revert = ReversibleAction(R.string.moved_to_top) {
|
||||
reorderSources(newPos, oldPos)
|
||||
}
|
||||
commitJob?.join()
|
||||
onActionDone.call(revert)
|
||||
}
|
||||
if (x.source == source) {
|
||||
oldPos = i
|
||||
break
|
||||
}
|
||||
}
|
||||
@Suppress("KotlinConstantConditions")
|
||||
if (oldPos != -1 && newPos != -1) {
|
||||
reorderSources(oldPos, newPos)
|
||||
val revert = ReversibleAction(R.string.moved_to_top) {
|
||||
reorderSources(newPos, oldPos)
|
||||
}
|
||||
onActionDone.call(revert)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,20 +116,15 @@ class SourcesManageViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun commit(snapshot: List<SourceConfigItem>) {
|
||||
val prevJob = commitJob
|
||||
commitJob = launchJob {
|
||||
prevJob?.cancelAndJoin()
|
||||
delay(500)
|
||||
val newSourcesList = snapshot.mapNotNull { x ->
|
||||
if (x is SourceConfigItem.SourceItem && x.isDraggable) {
|
||||
x.source
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
repository.setPositions(newSourcesList)
|
||||
yield()
|
||||
private fun reorderSources(oldPos: Int, newPos: Int) {
|
||||
val snapshot = content.value.toMutableList()
|
||||
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
|
||||
return
|
||||
}
|
||||
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
|
||||
return
|
||||
}
|
||||
snapshot.move(oldPos, newPos)
|
||||
saveSourcesOrder(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ buildscript {
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.4'
|
||||
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.21'
|
||||
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.48.1'
|
||||
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.49'
|
||||
classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:1.9.21-1.0.15'
|
||||
}
|
||||
}
|
||||
@@ -22,5 +22,5 @@ allprojects {
|
||||
}
|
||||
|
||||
tasks.register('clean', Delete) {
|
||||
delete rootProject.layout.buildDirectory
|
||||
delete rootProject.layout.buildDirectory
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user