From 484f92e531899d4a07c9f80474dc74652795a75d Mon Sep 17 00:00:00 2001 From: erik-everardo Date: Sun, 21 Jan 2024 17:20:42 -0600 Subject: [PATCH] feature: ver y eliminar sesiones --- .idea/assetWizardSettings.xml | 4 +- .../GenericItemsListRecyclerViewAdapter.kt | 85 ++++++++++++ .../generic_items_list/GenericListItem.kt | 10 ++ .../AccountSettingsRepositoryImpl.kt | 1 + .../presentation/SessionsViewModel.kt | 53 ++++++++ .../isolaatti/settings/ui/SessionsFragment.kt | 122 +++++++++++++++++- .../res/layout/fragment_settings_sessions.xml | 41 ++++-- app/src/main/res/layout/generic_list_item.xml | 53 ++++++++ .../main/res/menu/sessions_context_menu.xml | 11 ++ app/src/main/res/values/strings.xml | 4 + 10 files changed, 372 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/isolaatti/common/generic_items_list/GenericItemsListRecyclerViewAdapter.kt create mode 100644 app/src/main/java/com/isolaatti/common/generic_items_list/GenericListItem.kt create mode 100644 app/src/main/java/com/isolaatti/settings/presentation/SessionsViewModel.kt create mode 100644 app/src/main/res/layout/generic_list_item.xml create mode 100644 app/src/main/res/menu/sessions_context_menu.xml diff --git a/.idea/assetWizardSettings.xml b/.idea/assetWizardSettings.xml index 7c05a29..a92bcd9 100644 --- a/.idea/assetWizardSettings.xml +++ b/.idea/assetWizardSettings.xml @@ -308,7 +308,7 @@ @@ -318,7 +318,7 @@ diff --git a/app/src/main/java/com/isolaatti/common/generic_items_list/GenericItemsListRecyclerViewAdapter.kt b/app/src/main/java/com/isolaatti/common/generic_items_list/GenericItemsListRecyclerViewAdapter.kt new file mode 100644 index 0000000..da7359a --- /dev/null +++ b/app/src/main/java/com/isolaatti/common/generic_items_list/GenericItemsListRecyclerViewAdapter.kt @@ -0,0 +1,85 @@ +package com.isolaatti.common.generic_items_list + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.isolaatti.databinding.GenericListItemBinding + +class GenericItemsListRecyclerViewAdapter( + private val onClick: ((item: GenericListItem) -> Unit), + private val onItemsSelectedCountUpdate: ((count: Int) -> Unit)? = null, + private val onDeleteMode: ((enabled: Boolean) -> Unit)? = null, +) : ListAdapter, GenericItemsListRecyclerViewAdapter.GenericItemViewHolder>(getDiffCallback()) { + class GenericItemViewHolder(val genericListItemBinding: GenericListItemBinding) : ViewHolder(genericListItemBinding.root) + + + var deleteMode: Boolean = false + set(value) { + field = value + if(!value) { + currentList.forEach { it.delete = false } + } + notifyDataSetChanged() + } + + fun getSelectedItems(): List> { + return currentList.filter { it.delete } + } + companion object { + + fun getDiffCallback(): DiffUtil.ItemCallback>{ + return object: DiffUtil.ItemCallback>() { + override fun areItemsTheSame(oldItem: GenericListItem, newItem: GenericListItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: GenericListItem, newItem: GenericListItem): Boolean { + return oldItem == newItem + } + + } + } + + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GenericItemViewHolder { + return GenericItemViewHolder(GenericListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun onBindViewHolder(holder: GenericItemViewHolder, position: Int) { + val item = getItem(position) + holder.genericListItemBinding.title.text = item.title + holder.genericListItemBinding.subtitle.text = item.subtitle + + holder.genericListItemBinding.root.isEnabled = !item.disabled + holder.genericListItemBinding.checkbox.isEnabled = !item.disabled + + if(deleteMode) { + holder.genericListItemBinding.checkbox.visibility = View.VISIBLE + holder.genericListItemBinding.root.setOnClickListener { + holder.genericListItemBinding.checkbox.isChecked = !holder.genericListItemBinding.checkbox.isChecked + } + holder.genericListItemBinding.checkbox.setOnCheckedChangeListener { buttonView, isChecked -> + item.delete = isChecked + onItemsSelectedCountUpdate?.invoke(currentList.count { it.delete }) + } + holder.genericListItemBinding.checkbox.isChecked = item.delete + holder.genericListItemBinding.root.setOnLongClickListener(null) + } else { + holder.genericListItemBinding.checkbox.visibility = View.GONE + holder.genericListItemBinding.checkbox.isChecked = false + holder.genericListItemBinding.root.setOnClickListener { + onClick(item) + } + holder.genericListItemBinding.root.setOnLongClickListener { + item.delete = true + onDeleteMode?.invoke(true) + onItemsSelectedCountUpdate?.invoke(currentList.count { it.delete }) + true + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/common/generic_items_list/GenericListItem.kt b/app/src/main/java/com/isolaatti/common/generic_items_list/GenericListItem.kt new file mode 100644 index 0000000..e4b9078 --- /dev/null +++ b/app/src/main/java/com/isolaatti/common/generic_items_list/GenericListItem.kt @@ -0,0 +1,10 @@ +package com.isolaatti.common.generic_items_list + +data class GenericListItem ( + val id: T, + val title: String, + val subtitle: String = "" +) { + var delete: Boolean = false + var disabled: Boolean = false +} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/settings/data/repository/AccountSettingsRepositoryImpl.kt b/app/src/main/java/com/isolaatti/settings/data/repository/AccountSettingsRepositoryImpl.kt index b78346e..7239bf4 100644 --- a/app/src/main/java/com/isolaatti/settings/data/repository/AccountSettingsRepositoryImpl.kt +++ b/app/src/main/java/com/isolaatti/settings/data/repository/AccountSettingsRepositoryImpl.kt @@ -33,6 +33,7 @@ class AccountSettingsRepositoryImpl @Inject constructor( } override fun getSessions(): Flow>> = flow { + emit(Resource.Loading()) try { val response = accountSettingsApi.getSessions().awaitResponse() diff --git a/app/src/main/java/com/isolaatti/settings/presentation/SessionsViewModel.kt b/app/src/main/java/com/isolaatti/settings/presentation/SessionsViewModel.kt new file mode 100644 index 0000000..ea10dc1 --- /dev/null +++ b/app/src/main/java/com/isolaatti/settings/presentation/SessionsViewModel.kt @@ -0,0 +1,53 @@ +package com.isolaatti.settings.presentation + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.isolaatti.settings.data.remote.SessionsDto +import com.isolaatti.settings.domain.AccountSettingsRepository +import com.isolaatti.utils.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SessionsViewModel @Inject constructor(private val accountSettingsRepository: AccountSettingsRepository) : ViewModel() { + + val sessions: MutableLiveData> = MutableLiveData() + val loading: MutableLiveData = MutableLiveData() + + fun getSessions() { + viewModelScope.launch { + accountSettingsRepository.getSessions().onEach { + + when(it) { + is Resource.Error -> { + loading.postValue(false) + } + is Resource.Loading -> { + loading.postValue(true) + } + is Resource.Success -> { + loading.postValue(false) + sessions.postValue(it.data!!) + } + } + }.flowOn(Dispatchers.IO).launchIn(this) + } + } + + fun deleteSessions(ids: List) { + viewModelScope.launch { + accountSettingsRepository.signOutSessions(ids).onEach { + val newSessionsList = sessions.value?.filter { !ids.contains(it.id) } + if(newSessionsList != null) { + sessions.postValue(newSessionsList!!) + } + }.flowOn(Dispatchers.IO).launchIn(this) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/settings/ui/SessionsFragment.kt b/app/src/main/java/com/isolaatti/settings/ui/SessionsFragment.kt index 13a0e90..fa4bffd 100644 --- a/app/src/main/java/com/isolaatti/settings/ui/SessionsFragment.kt +++ b/app/src/main/java/com/isolaatti/settings/ui/SessionsFragment.kt @@ -1,22 +1,142 @@ package com.isolaatti.settings.ui import android.os.Bundle +import android.view.ActionMode import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.widget.LinearLayout import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.isolaatti.R +import com.isolaatti.common.generic_items_list.GenericItemsListRecyclerViewAdapter +import com.isolaatti.common.generic_items_list.GenericListItem import com.isolaatti.databinding.FragmentSettingsSessionsBinding +import com.isolaatti.settings.presentation.SessionsViewModel +import com.isolaatti.utils.Resource +import dagger.hilt.android.AndroidEntryPoint +import java.time.format.DateTimeFormatter +@AndroidEntryPoint class SessionsFragment : Fragment() { lateinit var viewBinding: FragmentSettingsSessionsBinding + private val viewModel: SessionsViewModel by viewModels() + private var adapter: GenericItemsListRecyclerViewAdapter? = null + + private val contextBarCallback: ActionMode.Callback = object: ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { + requireActivity().menuInflater.inflate(R.menu.images_context_menu, menu) + return true + } + + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { + return true + } + + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { + return when(item?.itemId) { + R.id.delete_item -> { + showDeleteDialog() + true + } + else -> false + } + } + + override fun onDestroyActionMode(mode: ActionMode?) { + adapter?.deleteMode = false + } + + } + + private var actionMode: ActionMode? = null + + private fun showDeleteDialog() { + val sessionsToDelete = adapter?.getSelectedItems() + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.delete_sessions) + .setMessage(R.string.delete_sessions_conf_dialog_message) + .setPositiveButton(R.string.yes_continue) { _, _ -> + val ids = sessionsToDelete?.map { it.id } + if(ids != null) { + viewModel.deleteSessions(ids) + } + actionMode?.finish() + } + .setNegativeButton(R.string.no, null) + .show() + } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { viewBinding = FragmentSettingsSessionsBinding.inflate(inflater) return viewBinding.root } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + adapter = GenericItemsListRecyclerViewAdapter( + onClick = {}, + onItemsSelectedCountUpdate = { + actionMode?.title = getString(R.string.sessions_selected_count, it) + actionMode?.menu?.findItem(R.id.delete_item)?.isEnabled = it > 0 + }, + onDeleteMode = { + adapter?.deleteMode = it + actionMode = requireActivity().startActionMode(contextBarCallback) + } + ) + viewBinding.recycler.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) + viewBinding.recycler.adapter = adapter + setupObservers() + setupListeners() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + viewModel.getSessions() + } + + private fun setupObservers() { + viewModel.sessions.observe(viewLifecycleOwner) { resource -> + viewBinding.loading.visibility = View.GONE + viewBinding.swipeToRefresh.isRefreshing = false + adapter?.submitList(resource?.map { sessionDto -> + val item = GenericListItem( + sessionDto.id, + title = "${if(sessionDto.current) getString(R.string.current) else ""} ${sessionDto.userAgent}", + subtitle = "${sessionDto.ip} - ${sessionDto.date.format( + DateTimeFormatter.ISO_DATE_TIME)}") + item.disabled = sessionDto.current + item + }) + } + + viewModel.loading.observe(viewLifecycleOwner) {loading -> + if(!viewBinding.swipeToRefresh.isRefreshing) { + viewBinding.loading.visibility = if(loading) View.VISIBLE else View.GONE + } + } + } + + private fun setupListeners() { + viewBinding.toolbar.setNavigationOnClickListener { + findNavController().popBackStack() + } + + viewBinding.swipeToRefresh.setOnRefreshListener { + viewModel.getSessions() + } + } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_settings_sessions.xml b/app/src/main/res/layout/fragment_settings_sessions.xml index 490e993..666db0b 100644 --- a/app/src/main/res/layout/fragment_settings_sessions.xml +++ b/app/src/main/res/layout/fragment_settings_sessions.xml @@ -1,17 +1,40 @@ - - + + + + + + + + - \ No newline at end of file + android:layout_gravity="center" + android:indeterminate="true" + android:visibility="gone" + tools:visibility="visible"/> + + \ No newline at end of file diff --git a/app/src/main/res/layout/generic_list_item.xml b/app/src/main/res/layout/generic_list_item.xml new file mode 100644 index 0000000..2468a23 --- /dev/null +++ b/app/src/main/res/layout/generic_list_item.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/sessions_context_menu.xml b/app/src/main/res/menu/sessions_context_menu.xml new file mode 100644 index 0000000..efb29de --- /dev/null +++ b/app/src/main/res/menu/sessions_context_menu.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98db913..8125508 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -152,4 +152,8 @@ Change password A password allows you to sign in securely. Keep this password safe. Sessions + Sessions selected: %d + Delete sessions + The selected sessions will become invalid, sign out those devices. + Current \ No newline at end of file