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> <PersistentState>
<option name="values"> <option name="values">
<map> <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> </map>
</option> </option>
</PersistentState> </PersistentState>
@ -318,7 +318,7 @@
</option> </option>
<option name="values"> <option name="values">
<map> <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" /> <entry key="sourceFile" value="C:\Users\erike\Downloads\face-kiss-wink-heart-solid.svg" />
</map> </map>
</option> </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 { override fun getSessions(): Flow<Resource<List<SessionsDto.SessionDto>>> = flow {
emit(Resource.Loading())
try { try {
val response = accountSettingsApi.getSessions().awaitResponse() 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 package com.isolaatti.settings.ui
import android.os.Bundle import android.os.Bundle
import android.view.ActionMode
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.fragment.app.Fragment 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.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() { class SessionsFragment : Fragment() {
lateinit var viewBinding: FragmentSettingsSessionsBinding 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( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View {
viewBinding = FragmentSettingsSessionsBinding.inflate(inflater) viewBinding = FragmentSettingsSessionsBinding.inflate(inflater)
return viewBinding.root 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"?> <?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:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<TextView <com.google.android.material.appbar.AppBarLayout
android:id="@+id/textView6" 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_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Sessions" android:layout_gravity="center"
app:layout_constraintBottom_toBottomOf="parent" android:indeterminate="true"
app:layout_constraintEnd_toEndOf="parent" android:visibility="gone"
app:layout_constraintStart_toStartOf="parent" tools:visibility="visible"/>
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> </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="change_password">Change password</string>
<string name="password_description">A password allows you to sign in securely. Keep this password safe.</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">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> </resources>