diff --git a/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/RemoteDataSourceModule.kt b/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/RemoteDataSourceModule.kt index a4fb587d19a..14efaaac634 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/RemoteDataSourceModule.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/RemoteDataSourceModule.kt @@ -4,7 +4,7 @@ * @author David González Verdugo * @author Jorge Aguado Recio * - * Copyright (C) 2025 ownCloud GmbH. + * Copyright (C) 2026 ownCloud GmbH. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2, @@ -33,6 +33,8 @@ import com.owncloud.android.data.capabilities.datasources.implementation.OCRemot import com.owncloud.android.data.capabilities.datasources.mapper.RemoteCapabilityMapper import com.owncloud.android.data.files.datasources.RemoteFileDataSource import com.owncloud.android.data.files.datasources.implementation.OCRemoteFileDataSource +import com.owncloud.android.data.members.datasources.RemoteMembersDataSource +import com.owncloud.android.data.members.datasources.implementation.OCRemoteMembersDataSource import com.owncloud.android.data.oauth.datasources.RemoteOAuthDataSource import com.owncloud.android.data.oauth.datasources.implementation.OCRemoteOAuthDataSource import com.owncloud.android.data.roles.datasources.RemoteRolesDataSource @@ -76,6 +78,7 @@ val remoteDataSourceModule = module { singleOf(::OCRemoteAuthenticationDataSource) bind RemoteAuthenticationDataSource::class singleOf(::OCRemoteCapabilitiesDataSource) bind RemoteCapabilitiesDataSource::class singleOf(::OCRemoteFileDataSource) bind RemoteFileDataSource::class + singleOf(::OCRemoteMembersDataSource) bind RemoteMembersDataSource::class singleOf(::OCRemoteOAuthDataSource) bind RemoteOAuthDataSource::class singleOf(::OCRemoteRolesDataSource) bind RemoteRolesDataSource::class singleOf(::OCRemoteServerInfoDataSource) bind RemoteServerInfoDataSource::class diff --git a/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/RepositoryModule.kt b/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/RepositoryModule.kt index ae4ea9fcf79..44e1e762781 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/RepositoryModule.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/RepositoryModule.kt @@ -6,7 +6,7 @@ * @author Juan Carlos Garrote Gascón * @author Jorge Aguado Recio * - * Copyright (C) 2025 ownCloud GmbH. + * Copyright (C) 2026 ownCloud GmbH. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2, @@ -28,6 +28,7 @@ import com.owncloud.android.data.authentication.repository.OCAuthenticationRepos import com.owncloud.android.data.capabilities.repository.OCCapabilityRepository import com.owncloud.android.data.files.repository.OCFileRepository import com.owncloud.android.data.folderbackup.repository.OCFolderBackupRepository +import com.owncloud.android.data.members.repository.OCMembersRepository import com.owncloud.android.data.oauth.repository.OCOAuthRepository import com.owncloud.android.data.roles.repository.OCRolesRepository import com.owncloud.android.data.server.repository.OCServerInfoRepository @@ -43,6 +44,7 @@ import com.owncloud.android.domain.authentication.oauth.OAuthRepository import com.owncloud.android.domain.automaticuploads.FolderBackupRepository import com.owncloud.android.domain.capabilities.CapabilityRepository import com.owncloud.android.domain.files.FileRepository +import com.owncloud.android.domain.members.MembersRepository import com.owncloud.android.domain.roles.RolesRepository import com.owncloud.android.domain.server.ServerInfoRepository import com.owncloud.android.domain.sharing.sharees.ShareeRepository @@ -61,6 +63,7 @@ val repositoryModule = module { factoryOf(::OCCapabilityRepository) bind CapabilityRepository::class factoryOf(::OCFileRepository) bind FileRepository::class factoryOf(::OCFolderBackupRepository) bind FolderBackupRepository::class + factoryOf(::OCMembersRepository) bind MembersRepository::class factoryOf(::OCOAuthRepository) bind OAuthRepository::class factoryOf(::OCRolesRepository) bind RolesRepository::class factoryOf(::OCServerInfoRepository) bind ServerInfoRepository::class diff --git a/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/UseCaseModule.kt b/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/UseCaseModule.kt index 90299a510ee..ae73313bf84 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/UseCaseModule.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/UseCaseModule.kt @@ -7,7 +7,7 @@ * @author Aitor Ballesteros Pavón * @author Jorge Aguado Recio * - * Copyright (C) 2025 ownCloud GmbH. + * Copyright (C) 2026 ownCloud GmbH. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2, @@ -107,6 +107,7 @@ import com.owncloud.android.domain.spaces.usecases.GetSpaceWithSpecialsByIdForAc import com.owncloud.android.domain.spaces.usecases.GetSpacesFromEveryAccountUseCaseAsStream import com.owncloud.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase import com.owncloud.android.domain.spaces.usecases.GetSpaceMembersUseCase +import com.owncloud.android.domain.members.usecases.SearchMembersUseCase import com.owncloud.android.domain.transfers.usecases.ClearSuccessfulTransferByIdUseCase import com.owncloud.android.domain.transfers.usecases.ClearSuccessfulTransfersUseCase import com.owncloud.android.domain.transfers.usecases.GetAllTransfersAsStreamUseCase @@ -306,4 +307,7 @@ val useCaseModule = module { // Roles factoryOf(::GetRolesAsyncUseCase) + + // Members + factoryOf(::SearchMembersUseCase) } diff --git a/owncloudApp/src/main/java/com/owncloud/android/extensions/SpaceMenuOptionExt.kt b/owncloudApp/src/main/java/com/owncloud/android/extensions/SpaceMenuOptionExt.kt index 99dc525a20f..4c65b00650d 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/extensions/SpaceMenuOptionExt.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/extensions/SpaceMenuOptionExt.kt @@ -31,7 +31,7 @@ fun SpaceMenuOption.toStringResId() = SpaceMenuOption.ENABLE -> R.string.enable_space SpaceMenuOption.DELETE -> R.string.delete_space SpaceMenuOption.SET_ICON -> R.string.set_space_icon - SpaceMenuOption.MEMBERS -> R.string.space_members + SpaceMenuOption.MEMBERS -> R.string.members_title } fun SpaceMenuOption.toDrawableResId() = diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/AddMemberFragment.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/AddMemberFragment.kt new file mode 100644 index 00000000000..28faaf41113 --- /dev/null +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/AddMemberFragment.kt @@ -0,0 +1,143 @@ +/** + * ownCloud Android client application + * + * @author Jorge Aguado Recio + * + * Copyright (C) 2026 ownCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.presentation.spaces.members + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.R +import com.owncloud.android.databinding.AddMemberFragmentBinding +import com.owncloud.android.domain.spaces.model.OCSpace +import com.owncloud.android.domain.spaces.model.SpaceMember +import com.owncloud.android.extensions.collectLatestLifecycleFlow +import com.owncloud.android.extensions.showErrorInSnackbar +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import timber.log.Timber + +class AddMemberFragment: Fragment() { + private var _binding: AddMemberFragmentBinding? = null + private val binding get() = _binding!! + + private val spaceMembersViewModel: SpaceMembersViewModel by viewModel { + parametersOf( + requireArguments().getString(ARG_ACCOUNT_NAME), + requireArguments().getParcelable(ARG_CURRENT_SPACE) + ) + } + + private lateinit var searchMembersAdapter: SearchMembersAdapter + private lateinit var recyclerView: RecyclerView + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = AddMemberFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + searchMembersAdapter = SearchMembersAdapter() + recyclerView = binding.membersRecyclerView + recyclerView.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = searchMembersAdapter + } + + val spaceMembers = requireArguments().getParcelableArrayList(ARG_SPACE_MEMBERS) ?: arrayListOf() + + collectLatestLifecycleFlow(spaceMembersViewModel.members) { uiState -> + if (uiState.isLoading) { + binding.indeterminateProgressBar.visibility = View.VISIBLE + binding.emptyDataParent.root.visibility = View.GONE + binding.membersRecyclerView.visibility = View.GONE + } else { + binding.indeterminateProgressBar.visibility = View.GONE + val listOfMembersFiltered = uiState.members.filter { member -> + !spaceMembers.any { spaceMember -> + spaceMember.id == "u:${member.id}" || spaceMember.id == "g:${member.id}" } + } + val hasMembers = listOfMembersFiltered.isNotEmpty() + showOrHideEmptyView(hasMembers) + if (hasMembers) searchMembersAdapter.setMembers(listOfMembersFiltered) + uiState.error?.let { + Timber.e(uiState.error, "Failed to retrieve available users and groups") + showErrorInSnackbar(R.string.members_search_failed, uiState.error) + } + } + } + + binding.searchBar.apply { + requestFocus() + setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean = true + + override fun onQueryTextChange(newText: String): Boolean { + if (newText.length > 2) { spaceMembersViewModel.searchMembers(newText) } else { spaceMembersViewModel.clearSearch() } + return true + } + }) + } + } + + private fun showOrHideEmptyView(hasMembers: Boolean) { + binding.membersRecyclerView.isVisible = hasMembers + binding.emptyDataParent.apply { + val shouldShow = !hasMembers && binding.searchBar.query.length > 2 + root.isVisible = shouldShow + if (shouldShow) { + listEmptyDatasetIcon.setImageResource(R.drawable.ic_share_generic_white) + listEmptyDatasetTitle.setText(R.string.members_search_failed) + listEmptyDatasetSubTitle.setText(R.string.members_search_empty) + } + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + requireActivity().setTitle(R.string.members_add) + } + + companion object { + private const val ARG_ACCOUNT_NAME = "ACCOUNT_NAME" + private const val ARG_CURRENT_SPACE = "CURRENT_SPACE" + private const val ARG_SPACE_MEMBERS = "SPACE_MEMBERS" + + fun newInstance( + accountName: String, + currentSpace: OCSpace, + spaceMembers: List + ): AddMemberFragment { + val args = Bundle().apply { + putString(ARG_ACCOUNT_NAME, accountName) + putParcelable(ARG_CURRENT_SPACE, currentSpace) + putParcelableArrayList(ARG_SPACE_MEMBERS, ArrayList(spaceMembers)) + } + return AddMemberFragment().apply { + arguments = args + } + } + } +} diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SearchMembersAdapter.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SearchMembersAdapter.kt new file mode 100644 index 00000000000..5ba99307f46 --- /dev/null +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SearchMembersAdapter.kt @@ -0,0 +1,82 @@ +/** + * ownCloud Android client application + * + * @author Jorge Aguado Recio + * + * Copyright (C) 2026 ownCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.presentation.spaces.members + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.R +import com.owncloud.android.databinding.MemberItemBinding +import com.owncloud.android.domain.members.model.OCMember +import com.owncloud.android.utils.PreferenceUtils + +class SearchMembersAdapter: RecyclerView.Adapter() { + + private var members = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchMembersViewHolder { + val inflater = LayoutInflater.from(parent.context) + + val view = inflater.inflate(R.layout.member_item, parent, false) + view.filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(parent.context) + + return SearchMembersViewHolder(view) + } + + override fun onBindViewHolder(holder: SearchMembersViewHolder, position: Int) { + val member = members[position] + + holder.binding.apply { + val isGroup = member.surname == GROUP_SURNAME + memberIcon.setImageResource(if (isGroup) R.drawable.ic_group else R.drawable.ic_user) + memberName.text = member.displayName + memberName.contentDescription = holder.itemView.context.getString( + if (isGroup) R.string.content_description_member_group else R.string.content_description_member_user, member.displayName + ) + memberRole.text = if (isGroup) { + holder.itemView.context.getString(R.string.member_type_group) + } else { + if (member.surname == USER_SURNAME) holder.itemView.context.getString(R.string.member_type_user) else member.surname + } + } + } + + override fun getItemCount(): Int = members.size + + fun setMembers(members: List) { + val diffCallback = SpaceMembersDiffUtil(this.members, members) + val diffResult = DiffUtil.calculateDiff(diffCallback) + this.members.clear() + this.members.addAll(members) + diffResult.dispatchUpdatesTo(this) + } + + class SearchMembersViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val binding = MemberItemBinding.bind(itemView) + } + + companion object { + private const val USER_SURNAME = "User" + private const val GROUP_SURNAME = "Group" + } +} diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersActivity.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersActivity.kt index 1d96476ef63..4c47841792b 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersActivity.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersActivity.kt @@ -3,7 +3,7 @@ * * @author Jorge Aguado Recio * - * Copyright (C) 2025 ownCloud GmbH. + * Copyright (C) 2026 ownCloud GmbH. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2, @@ -25,21 +25,43 @@ import android.view.Menu import android.view.MenuItem import androidx.fragment.app.transaction import com.owncloud.android.R +import com.owncloud.android.databinding.MembersActivityBinding import com.owncloud.android.domain.spaces.model.OCSpace +import com.owncloud.android.domain.spaces.model.SpaceMember import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.utils.DisplayUtils -class SpaceMembersActivity: FileActivity() { +class SpaceMembersActivity: FileActivity(), SpaceMembersFragment.SpaceMemberFragmentListener { + + private lateinit var binding: MembersActivityBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.members_activity) + binding = MembersActivityBinding.inflate(layoutInflater) + setContentView(binding.root) setupStandardToolbar(title = null, displayHomeAsUpEnabled = true, homeButtonEnabled = true, displayShowTitleEnabled = true) supportActionBar?.setHomeActionContentDescription(R.string.common_back) - val currentSpace = intent.getParcelableExtra(EXTRA_SPACE) + val currentSpace = intent.getParcelableExtra(EXTRA_SPACE) ?: return + binding.apply { + itemName.text = currentSpace.name + currentSpace.quota?.let { quota -> + val usedQuota = quota.used + val totalQuota = quota.total + itemSize.text = when { + usedQuota == null -> getString(R.string.drawer_unavailable_used_storage) + totalQuota == 0L -> DisplayUtils.bytesToHumanReadable(usedQuota, baseContext, true) + else -> getString( + R.string.drawer_quota, + DisplayUtils.bytesToHumanReadable(usedQuota, baseContext, true), + DisplayUtils.bytesToHumanReadable(totalQuota, baseContext, true), + quota.getRelative().toString()) + } + } + } supportFragmentManager.transaction { if (savedInstanceState == null && currentSpace != null) { @@ -59,8 +81,19 @@ class SpaceMembersActivity: FileActivity() { super.onOptionsItemSelected(item) } + override fun addMember(space: OCSpace, spaceMembers: List) { + val addMemberFragment = AddMemberFragment.newInstance(account.name, space, spaceMembers) + val transaction = supportFragmentManager.beginTransaction() + transaction.apply { + replace(R.id.members_fragment_container, addMemberFragment, TAG_ADD_MEMBER_FRAGMENT) + addToBackStack(null) + commit() + } + } + companion object { private const val TAG_SPACE_MEMBERS_FRAGMENT = "SPACE_MEMBERS_FRAGMENT" + private const val TAG_ADD_MEMBER_FRAGMENT ="ADD_MEMBER_FRAGMENT" const val EXTRA_SPACE = "EXTRA_SPACE" } diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersAdapter.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersAdapter.kt index f33a0bf9283..d96182f3c9f 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersAdapter.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersAdapter.kt @@ -73,7 +73,9 @@ class SpaceMembersAdapter: RecyclerView.Adapter) { this.rolesMap = roles.associate { it.id to it.displayName } - this.members = spaceMembers.members.sortedByDescending { member -> roles.indexOfFirst { it.id in member.roles } } + this.members = spaceMembers.members.sortedWith(compareByDescending { + member -> roles.indexOfFirst { it.id in member.roles } }.thenBy { member -> member.displayName } + ) notifyDataSetChanged() } diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersDiffUtil.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersDiffUtil.kt new file mode 100644 index 00000000000..f200eacd186 --- /dev/null +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersDiffUtil.kt @@ -0,0 +1,47 @@ +/** + * ownCloud Android client application + * + * @author Jorge Aguado Recio + * + * Copyright (C) 2026 ownCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.owncloud.android.presentation.spaces.members + +import androidx.recyclerview.widget.DiffUtil +import com.owncloud.android.domain.members.model.OCMember + +class SpaceMembersDiffUtil( + private val oldList: List, + private val newList: List +) : 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 oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + + return ((oldItem.id == newItem.id) && (oldItem.displayName == newItem.displayName) && (oldItem.surname == newItem.surname)) + } +} diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersFragment.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersFragment.kt index 00ed053774d..c5f1e00c35c 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersFragment.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersFragment.kt @@ -3,7 +3,7 @@ * * @author Jorge Aguado Recio * - * Copyright (C) 2025 ownCloud GmbH. + * Copyright (C) 2026 ownCloud GmbH. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2, @@ -20,6 +20,7 @@ package com.owncloud.android.presentation.spaces.members +import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -31,11 +32,12 @@ import com.owncloud.android.R import com.owncloud.android.databinding.MembersFragmentBinding import com.owncloud.android.domain.roles.model.OCRole import com.owncloud.android.domain.spaces.model.OCSpace +import com.owncloud.android.domain.spaces.model.SpaceMember import com.owncloud.android.extensions.collectLatestLifecycleFlow import com.owncloud.android.presentation.common.UIResult -import com.owncloud.android.utils.DisplayUtils import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import timber.log.Timber class SpaceMembersFragment : Fragment() { private var _binding: MembersFragmentBinding? = null @@ -52,6 +54,8 @@ class SpaceMembersFragment : Fragment() { private lateinit var recyclerView: RecyclerView private var roles: List = emptyList() + private var spaceMembers: List = emptyList() + private var listener: SpaceMemberFragmentListener? = null override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = MembersFragmentBinding.inflate(inflater, container, false) @@ -67,6 +71,8 @@ class SpaceMembersFragment : Fragment() { adapter = spaceMembersAdapter } + val currentSpace = requireArguments().getParcelable(ARG_CURRENT_SPACE) ?: return + collectLatestLifecycleFlow(spaceMembersViewModel.roles) { event -> event?.let { when (val uiResult = event.peekContent()) { @@ -77,7 +83,9 @@ class SpaceMembersFragment : Fragment() { } } is UIResult.Loading -> { } - is UIResult.Error -> { } + is UIResult.Error -> { + Timber.e(uiResult.error, "Failed to retrieve platform roles") + } } } } @@ -87,37 +95,64 @@ class SpaceMembersFragment : Fragment() { when (val uiResult = event.peekContent()) { is UIResult.Success -> { uiResult.data?.let { - if (roles.isNotEmpty()) { spaceMembersAdapter.setSpaceMembers(it, roles) } + if (roles.isNotEmpty()) { + spaceMembersAdapter.setSpaceMembers(it, roles) + spaceMembers = it.members + } } } is UIResult.Loading -> { } - is UIResult.Error -> { } + is UIResult.Error -> { + Timber.e(uiResult.error, "Failed to retrieve space members for space: ${currentSpace.id} (${currentSpace?.id})") + } } } } - val currentSpace = requireArguments().getParcelable(ARG_CURRENT_SPACE) ?: return - binding.apply { - itemName.text = currentSpace.name - currentSpace.quota?.let { quota -> - val usedQuota = quota.used - val totalQuota = quota.total - itemSize.text = when { - usedQuota == null -> getString(R.string.drawer_unavailable_used_storage) - totalQuota == 0L -> DisplayUtils.bytesToHumanReadable(usedQuota, requireContext(), true) - else -> getString( - R.string.drawer_quota, - DisplayUtils.bytesToHumanReadable(usedQuota, requireContext(), true), - DisplayUtils.bytesToHumanReadable(totalQuota, requireContext(), true), - quota.getRelative().toString()) + collectLatestLifecycleFlow(spaceMembersViewModel.spacePermissions) { event -> + event?.let { + when (val uiResult = event.peekContent()) { + is UIResult.Success -> { + uiResult.data?.let { spacePermissions -> + if (DRIVES_CREATE_PERMISSION in spacePermissions) { binding.addMemberButton.visibility = View.VISIBLE } + } + } + is UIResult.Loading -> { } + is UIResult.Error -> { + Timber.e(uiResult.error, "Failed to retrieve space permissions for space: ${currentSpace.id} (${currentSpace?.id})") + } } } } + + binding.addMemberButton.setOnClickListener { + listener?.addMember(currentSpace, spaceMembers) + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + requireActivity().setTitle(R.string.space_members_label) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + try { + listener = context as SpaceMemberFragmentListener? + } catch (e: ClassCastException) { + Timber.e(e, "The activity attached does not implement SpaceMemberFragmentListener") + throw ClassCastException(activity.toString() + " must implement SpaceMemberFragmentListener") + } + } + + interface SpaceMemberFragmentListener { + fun addMember(space: OCSpace, spaceMembers: List) } companion object { private const val ARG_CURRENT_SPACE = "CURRENT_SPACE" private const val ARG_ACCOUNT_NAME = "ACCOUNT_NAME" + private const val DRIVES_CREATE_PERMISSION = "libre.graph/driveItem/permissions/create" fun newInstance( accountName: String, diff --git a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersViewModel.kt b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersViewModel.kt index 72b4fa673fb..632f11e7f23 100644 --- a/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersViewModel.kt +++ b/owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersViewModel.kt @@ -3,7 +3,7 @@ * * @author Jorge Aguado Recio * - * Copyright (C) 2025 ownCloud GmbH. + * Copyright (C) 2026 ownCloud GmbH. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2, @@ -21,21 +21,32 @@ package com.owncloud.android.presentation.spaces.members import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.owncloud.android.domain.UseCaseResult +import com.owncloud.android.domain.members.model.OCMember import com.owncloud.android.domain.roles.model.OCRole import com.owncloud.android.domain.spaces.model.OCSpace import com.owncloud.android.domain.spaces.model.SpaceMembers import com.owncloud.android.domain.spaces.usecases.GetSpaceMembersUseCase import com.owncloud.android.domain.roles.usecases.GetRolesAsyncUseCase +import com.owncloud.android.domain.spaces.usecases.GetSpacePermissionsAsyncUseCase +import com.owncloud.android.domain.members.usecases.SearchMembersUseCase import com.owncloud.android.domain.utils.Event import com.owncloud.android.extensions.ViewModelExt.runUseCaseWithResult import com.owncloud.android.presentation.common.UIResult import com.owncloud.android.providers.CoroutinesDispatcherProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch class SpaceMembersViewModel( private val getRolesAsyncUseCase: GetRolesAsyncUseCase, private val getSpaceMembersUseCase: GetSpaceMembersUseCase, + private val getSpacePermissionsAsyncUseCase: GetSpacePermissionsAsyncUseCase, + private val searchMembersUseCase: SearchMembersUseCase, private val accountName: String, private val space: OCSpace, private val coroutineDispatcherProvider: CoroutinesDispatcherProvider @@ -47,6 +58,14 @@ class SpaceMembersViewModel( private val _spaceMembers = MutableStateFlow>?>(null) val spaceMembers: StateFlow>?> = _spaceMembers + private val _spacePermissions = MutableStateFlow>>?>(null) + val spacePermissions: StateFlow>>?> = _spacePermissions + + private val _members: MutableSharedFlow = MutableSharedFlow() + val members: SharedFlow = _members + + private var searchJob: Job? = null + init { runUseCaseWithResult( coroutineDispatcher = coroutineDispatcherProvider.io, @@ -56,6 +75,16 @@ class SpaceMembersViewModel( showLoading = false, requiresConnection = true ) + + runUseCaseWithResult( + coroutineDispatcher = coroutineDispatcherProvider.io, + flow = _spacePermissions, + useCase = getSpacePermissionsAsyncUseCase, + useCaseParams = GetSpacePermissionsAsyncUseCase.Params(accountName = accountName, spaceId = space.id), + showLoading = false, + requiresConnection = true + ) + } fun getSpaceMembers() = runUseCaseWithResult( @@ -67,4 +96,26 @@ class SpaceMembersViewModel( requiresConnection = true ) + fun searchMembers(query: String) { + searchJob?.cancel() + searchJob = viewModelScope.launch(coroutineDispatcherProvider.io) { + _members.emit(MembersUIState(members = emptyList(), isLoading = true , error = null)) + when (val result = searchMembersUseCase(SearchMembersUseCase.Params(accountName, query))) { + is UseCaseResult.Success -> _members.emit(MembersUIState(members = result.data, isLoading = false, error = null)) + is UseCaseResult.Error -> _members.emit(MembersUIState(members = emptyList(), isLoading = false, error = result.getThrowableOrNull())) + } + } + } + + fun clearSearch() { + viewModelScope.launch(coroutineDispatcherProvider.io) { + _members.emit(MembersUIState(members = emptyList(), isLoading = false , error = null)) + } + } + + data class MembersUIState ( + val members: List, + val isLoading: Boolean, + val error: Throwable? + ) } diff --git a/owncloudApp/src/main/res/layout/add_member_fragment.xml b/owncloudApp/src/main/res/layout/add_member_fragment.xml new file mode 100644 index 00000000000..c03bd6e40b4 --- /dev/null +++ b/owncloudApp/src/main/res/layout/add_member_fragment.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + diff --git a/owncloudApp/src/main/res/layout/member_item.xml b/owncloudApp/src/main/res/layout/member_item.xml index ab5f52997ee..c23fa978ec3 100644 --- a/owncloudApp/src/main/res/layout/member_item.xml +++ b/owncloudApp/src/main/res/layout/member_item.xml @@ -1,7 +1,7 @@ - + + + + + + + + + +