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