feature: ver y eliminar sesiones

This commit is contained in:
erik-everardo 2024-01-21 17:20:42 -06:00
parent a1c627ca59
commit 484f92e531
10 changed files with 372 additions and 12 deletions

View File

@ -308,7 +308,7 @@
<PersistentState>
<option name="values">
<map>
<entry key="url" value="file:/$USER_HOME$/AppData/Local/Android/Sdk/icons/material/materialicons/file_upload/baseline_file_upload_24.xml" />
<entry key="url" value="file:/$USER_HOME$/AppData/Local/Android/Sdk/icons/material/materialicons/password/baseline_password_24.xml" />
</map>
</option>
</PersistentState>
@ -318,7 +318,7 @@
</option>
<option name="values">
<map>
<entry key="outputName" value="baseline_file_upload_24" />
<entry key="outputName" value="baseline_password_24" />
<entry key="sourceFile" value="C:\Users\erike\Downloads\face-kiss-wink-heart-solid.svg" />
</map>
</option>

View File

@ -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<T>(
private val onClick: ((item: GenericListItem<T>) -> Unit),
private val onItemsSelectedCountUpdate: ((count: Int) -> Unit)? = null,
private val onDeleteMode: ((enabled: Boolean) -> Unit)? = null,
) : ListAdapter<GenericListItem<T>, GenericItemsListRecyclerViewAdapter.GenericItemViewHolder>(getDiffCallback<T>()) {
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<GenericListItem<T>> {
return currentList.filter { it.delete }
}
companion object {
fun <T> getDiffCallback(): DiffUtil.ItemCallback<GenericListItem<T>>{
return object: DiffUtil.ItemCallback<GenericListItem<T>>() {
override fun areItemsTheSame(oldItem: GenericListItem<T>, newItem: GenericListItem<T>): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: GenericListItem<T>, newItem: GenericListItem<T>): 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
}
}
}
}

View File

@ -0,0 +1,10 @@
package com.isolaatti.common.generic_items_list
data class GenericListItem <T>(
val id: T,
val title: String,
val subtitle: String = ""
) {
var delete: Boolean = false
var disabled: Boolean = false
}

View File

@ -33,6 +33,7 @@ class AccountSettingsRepositoryImpl @Inject constructor(
}
override fun getSessions(): Flow<Resource<List<SessionsDto.SessionDto>>> = flow {
emit(Resource.Loading())
try {
val response = accountSettingsApi.getSessions().awaitResponse()

View File

@ -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<List<SessionsDto.SessionDto>> = MutableLiveData()
val loading: MutableLiveData<Boolean> = 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<String>) {
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)
}
}
}

View File

@ -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<String>? = 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()
}
}
}

View File

@ -1,17 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/textView6"
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/sessions"
app:navigationIcon="@drawable/baseline_arrow_back_24"/>
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_to_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sessions"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
android:layout_gravity="center"
android:indeterminate="true"
android:visibility="gone"
tools:visibility="visible"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="70dp"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="4dp"
style="@style/Widget.Material3.CardView.Filled"
android:clickable="true"
android:focusable="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="6dp">
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:lines="1"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@+id/subtitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@id/checkbox"
app:layout_constraintTop_toTopOf="parent"
tools:text="Title" />
<TextView
android:id="@+id/subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:lines="1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/checkbox"
app:layout_constraintTop_toBottomOf="@+id/title"
tools:text="Subtitle" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/delete_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:showAsAction="always"
android:icon="@drawable/baseline_delete_24"
android:title="@string/delete" />
</menu>

View File

@ -152,4 +152,8 @@
<string name="change_password">Change password</string>
<string name="password_description">A password allows you to sign in securely. Keep this password safe.</string>
<string name="sessions">Sessions</string>
<string name="sessions_selected_count">Sessions selected: %d</string>
<string name="delete_sessions">Delete sessions</string>
<string name="delete_sessions_conf_dialog_message">The selected sessions will become invalid, sign out those devices.</string>
<string name="current">Current</string>
</resources>