Skip to content

Commit a361902

Browse files
authored
Merge pull request #4764 from owncloud/feature/add_a_member_to_a_space
[FEATURE REQUEST] Add a member to a space: Set permissions and expiration date
2 parents 3dc15ca + 85a5edc commit a361902

File tree

40 files changed

+1076
-101
lines changed

40 files changed

+1076
-101
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ ownCloud admins and users.
4444
* Change - Migrate tests to the new kotlinx-coroutines-test API: [#4710](https://github.com/owncloud/android/issues/4710)
4545
* Change - Increase rating dialog delay: [#4744](https://github.com/owncloud/android/pull/4744)
4646
* Enhancement - Show members of a space: [#4612](https://github.com/owncloud/android/issues/4612)
47+
* Enhancement - Add a member to a space: [#4613](https://github.com/owncloud/android/issues/4613)
4748
* Enhancement - Set emoji as space image: [#4707](https://github.com/owncloud/android/issues/4707)
4849
* Enhancement - Workflow to build APK: [#4751](https://github.com/owncloud/android/pull/4751)
4950
* Enhancement - Workflow to check Conventional Commits: [#4759](https://github.com/owncloud/android/pull/4759)
@@ -85,6 +86,16 @@ ownCloud admins and users.
8586
https://github.com/owncloud/android/pull/4728
8687
https://github.com/owncloud/android/pull/4765
8788

89+
* Enhancement - Add a member to a space: [#4613](https://github.com/owncloud/android/issues/4613)
90+
91+
A new option to add members to a space has been added. It will be only visible
92+
for users with proper permissions.
93+
94+
https://github.com/owncloud/android/issues/4613
95+
https://github.com/owncloud/android/issues/4740
96+
https://github.com/owncloud/android/pull/4754
97+
https://github.com/owncloud/android/pull/4764
98+
8899
* Enhancement - Set emoji as space image: [#4707](https://github.com/owncloud/android/issues/4707)
89100

90101
A new option to set an emoji as space image has been added to the bottom sheet,

changelog/unreleased/4754

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Enhancement: Add a member to a space
2+
3+
A new option to add members to a space has been added. It will be only visible for users with proper permissions.
4+
5+
https://github.com/owncloud/android/issues/4613
6+
https://github.com/owncloud/android/issues/4740
7+
https://github.com/owncloud/android/pull/4754
8+
https://github.com/owncloud/android/pull/4764

owncloudApp/src/main/java/com/owncloud/android/dependecyinjection/UseCaseModule.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import com.owncloud.android.domain.spaces.usecases.GetSpaceWithSpecialsByIdForAc
107107
import com.owncloud.android.domain.spaces.usecases.GetSpacesFromEveryAccountUseCaseAsStream
108108
import com.owncloud.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase
109109
import com.owncloud.android.domain.spaces.usecases.GetSpaceMembersUseCase
110+
import com.owncloud.android.domain.members.usecases.AddMemberUseCase
110111
import com.owncloud.android.domain.members.usecases.SearchMembersUseCase
111112
import com.owncloud.android.domain.transfers.usecases.ClearSuccessfulTransferByIdUseCase
112113
import com.owncloud.android.domain.transfers.usecases.ClearSuccessfulTransfersUseCase
@@ -309,5 +310,6 @@ val useCaseModule = module {
309310
factoryOf(::GetRolesAsyncUseCase)
310311

311312
// Members
313+
factoryOf(::AddMemberUseCase)
312314
factoryOf(::SearchMembersUseCase)
313315
}

owncloudApp/src/main/java/com/owncloud/android/extensions/ThrowableExt.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* @author David González Verdugo
55
* @author Jorge Aguado Recio
66
*
7-
* Copyright (C) 2025 ownCloud GmbH.
7+
* Copyright (C) 2026 ownCloud GmbH.
88
*
99
* This program is free software: you can redistribute it and/or modify
1010
* it under the terms of the GNU General Public License version 2,
@@ -27,6 +27,7 @@ import com.owncloud.android.domain.exceptions.AccountNotNewException
2727
import com.owncloud.android.domain.exceptions.AccountNotTheSameException
2828
import com.owncloud.android.domain.exceptions.BadOcVersionException
2929
import com.owncloud.android.domain.exceptions.ConflictException
30+
import com.owncloud.android.domain.exceptions.ConflictMemberException
3031
import com.owncloud.android.domain.exceptions.CopyIntoDescendantException
3132
import com.owncloud.android.domain.exceptions.CopyIntoSameFolderException
3233
import com.owncloud.android.domain.exceptions.FileAlreadyExistsException
@@ -71,6 +72,7 @@ fun Throwable.parseError(
7172
is AccountNotTheSameException -> resources.getString(R.string.auth_account_not_the_same)
7273
is BadOcVersionException -> resources.getString(R.string.auth_bad_oc_version_title)
7374
is ConflictException -> resources.getString(R.string.error_conflict)
75+
is ConflictMemberException -> resources.getString(R.string.members_add_conflict_error)
7476
is CopyIntoDescendantException -> resources.getString(R.string.copy_file_invalid_into_descendent)
7577
is CopyIntoSameFolderException -> resources.getString(R.string.copy_file_invalid_overwrite)
7678
is FileAlreadyExistsException -> resources.getString(R.string.file_already_exists)

owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/AddMemberFragment.kt

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
package com.owncloud.android.presentation.spaces.members
2222

23+
import android.app.DatePickerDialog
24+
import android.icu.util.Calendar
2325
import android.os.Bundle
2426
import android.view.LayoutInflater
2527
import android.view.View
@@ -30,15 +32,23 @@ import androidx.recyclerview.widget.LinearLayoutManager
3032
import androidx.recyclerview.widget.RecyclerView
3133
import com.owncloud.android.R
3234
import com.owncloud.android.databinding.AddMemberFragmentBinding
35+
import com.owncloud.android.domain.members.model.OCMember
36+
import com.owncloud.android.domain.members.model.OCMemberType
37+
import com.owncloud.android.domain.roles.model.OCRole
3338
import com.owncloud.android.domain.spaces.model.OCSpace
3439
import com.owncloud.android.domain.spaces.model.SpaceMember
3540
import com.owncloud.android.extensions.collectLatestLifecycleFlow
3641
import com.owncloud.android.extensions.showErrorInSnackbar
42+
import com.owncloud.android.presentation.common.UIResult
43+
import com.owncloud.android.utils.DisplayUtils
3744
import org.koin.androidx.viewmodel.ext.android.viewModel
3845
import org.koin.core.parameter.parametersOf
3946
import timber.log.Timber
47+
import java.text.SimpleDateFormat
48+
import java.util.Locale
49+
import java.util.TimeZone
4050

41-
class AddMemberFragment: Fragment() {
51+
class AddMemberFragment: Fragment(), SearchMembersAdapter.SearchMembersAdapterListener {
4252
private var _binding: AddMemberFragmentBinding? = null
4353
private val binding get() = _binding!!
4454

@@ -50,6 +60,7 @@ class AddMemberFragment: Fragment() {
5060
}
5161

5262
private lateinit var searchMembersAdapter: SearchMembersAdapter
63+
private lateinit var rolesAdapter: SpaceRolesAdapter
5364
private lateinit var recyclerView: RecyclerView
5465

5566
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -59,7 +70,7 @@ class AddMemberFragment: Fragment() {
5970

6071
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
6172
super.onViewCreated(view, savedInstanceState)
62-
searchMembersAdapter = SearchMembersAdapter()
73+
searchMembersAdapter = SearchMembersAdapter(this)
6374
recyclerView = binding.membersRecyclerView
6475
recyclerView.apply {
6576
layoutManager = LinearLayoutManager(requireContext())
@@ -89,8 +100,49 @@ class AddMemberFragment: Fragment() {
89100
}
90101
}
91102

103+
collectLatestLifecycleFlow(spaceMembersViewModel.addMemberUIState) { uiState ->
104+
uiState?.let {
105+
binding.apply {
106+
searchMemberLayout.visibility = View.GONE
107+
addMemberLayout.visibility = View.VISIBLE
108+
inviteMemberButton.visibility = View.VISIBLE
109+
}
110+
it.selectedMember?.let { member ->
111+
bindSelectedMember(member)
112+
}
113+
it.selectedExpirationDate?.let { expirationDate ->
114+
binding.expirationDateLayout.expirationDateValue.apply {
115+
visibility = View.VISIBLE
116+
text = DisplayUtils.displayDateToHumanReadable(expirationDate)
117+
}
118+
}
119+
bindRoles(uiState.selectedRole?.id)
120+
bindDatePickerDialog()
121+
122+
binding.inviteMemberButton.setOnClickListener {
123+
uiState.selectedMember?.let { selectedMember ->
124+
uiState.selectedRole?.let { selectedRole ->
125+
spaceMembersViewModel.addMember(selectedMember, selectedRole.id, uiState.selectedExpirationDate)
126+
}
127+
}
128+
}
129+
}
130+
}
131+
132+
collectLatestLifecycleFlow(spaceMembersViewModel.addMemberResultFlow) { event ->
133+
event?.peekContent()?.let { uiResult ->
134+
when (uiResult) {
135+
is UIResult.Loading -> { }
136+
is UIResult.Success -> requireActivity().onBackPressed()
137+
is UIResult.Error -> showErrorInSnackbar(R.string.members_add_failed, uiResult.error)
138+
}
139+
}
140+
}
141+
92142
binding.searchBar.apply {
93-
requestFocus()
143+
if (savedInstanceState == null) {
144+
requestFocus()
145+
}
94146
setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener {
95147
override fun onQueryTextSubmit(query: String): Boolean = true
96148

@@ -120,20 +172,91 @@ class AddMemberFragment: Fragment() {
120172
requireActivity().setTitle(R.string.members_add)
121173
}
122174

175+
override fun onMemberClick(member: OCMember) {
176+
spaceMembersViewModel.onMemberSelected(member)
177+
}
178+
179+
private fun bindSelectedMember(member: OCMember) {
180+
binding.selectedMemberLayout.apply {
181+
memberIcon.setImageResource(if (member.type == OCMemberType.GROUP) R.drawable.ic_group else R.drawable.ic_user)
182+
memberName.text = member.displayName
183+
memberRole.text = member.surname
184+
}
185+
}
186+
187+
private fun bindRoles(selectedRoleId: String?) {
188+
val roles = requireArguments().getParcelableArrayList<OCRole>(ARG_ROLES) ?: arrayListOf()
189+
rolesAdapter = SpaceRolesAdapter(onRoleSelected = {
190+
binding.inviteMemberButton.isEnabled = true
191+
spaceMembersViewModel.onRoleSelected(it)
192+
})
193+
binding.rolesRecyclerView.apply {
194+
layoutManager = LinearLayoutManager(requireContext())
195+
adapter = rolesAdapter
196+
}
197+
rolesAdapter.setRoles(roles)
198+
selectedRoleId?.let {
199+
binding.inviteMemberButton.isEnabled = true
200+
rolesAdapter.setSelectedRole(it)
201+
}
202+
}
203+
204+
private fun bindDatePickerDialog() {
205+
binding.expirationDateLayout.expirationDateSwitch.setOnCheckedChangeListener { _, isChecked ->
206+
if (isChecked) {
207+
val calendar = Calendar.getInstance()
208+
209+
DatePickerDialog(
210+
requireContext(),
211+
{ _, selectedYear, selectedMonth, selectedDay ->
212+
calendar.set(selectedYear, selectedMonth, selectedDay, 23, 59, 59)
213+
calendar.set(Calendar.MILLISECOND, 999)
214+
val formatter = SimpleDateFormat(DisplayUtils.DATE_FORMAT_ISO, Locale.ROOT)
215+
formatter.timeZone = TimeZone.getTimeZone("UTC")
216+
val isoExpirationDate = formatter.format(calendar.time)
217+
spaceMembersViewModel.onExpirationDateSelected(isoExpirationDate)
218+
binding.expirationDateLayout.expirationDateValue.apply {
219+
visibility = View.VISIBLE
220+
text = DisplayUtils.displayDateToHumanReadable(isoExpirationDate)
221+
}
222+
},
223+
calendar.get(Calendar.YEAR),
224+
calendar.get(Calendar.MONTH),
225+
calendar.get(Calendar.DAY_OF_MONTH)
226+
).apply {
227+
datePicker.minDate = calendar.timeInMillis
228+
show()
229+
setOnCancelListener {
230+
binding.expirationDateLayout.apply {
231+
expirationDateSwitch.isChecked = false
232+
expirationDateValue.visibility = View.GONE
233+
}
234+
}
235+
}
236+
} else {
237+
binding.expirationDateLayout.expirationDateValue.visibility = View.GONE
238+
spaceMembersViewModel.onExpirationDateSelected(null)
239+
}
240+
}
241+
}
242+
123243
companion object {
124244
private const val ARG_ACCOUNT_NAME = "ACCOUNT_NAME"
125245
private const val ARG_CURRENT_SPACE = "CURRENT_SPACE"
126246
private const val ARG_SPACE_MEMBERS = "SPACE_MEMBERS"
247+
private const val ARG_ROLES = "ROLES"
127248

128249
fun newInstance(
129250
accountName: String,
130251
currentSpace: OCSpace,
131-
spaceMembers: List<SpaceMember>
252+
spaceMembers: List<SpaceMember>,
253+
roles: List<OCRole>
132254
): AddMemberFragment {
133255
val args = Bundle().apply {
134256
putString(ARG_ACCOUNT_NAME, accountName)
135257
putParcelable(ARG_CURRENT_SPACE, currentSpace)
136258
putParcelableArrayList(ARG_SPACE_MEMBERS, ArrayList(spaceMembers))
259+
putParcelableArrayList(ARG_ROLES, ArrayList(roles))
137260
}
138261
return AddMemberFragment().apply {
139262
arguments = args

owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SearchMembersAdapter.kt

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ import androidx.recyclerview.widget.RecyclerView
2828
import com.owncloud.android.R
2929
import com.owncloud.android.databinding.MemberItemBinding
3030
import com.owncloud.android.domain.members.model.OCMember
31+
import com.owncloud.android.domain.members.model.OCMemberType
3132
import com.owncloud.android.utils.PreferenceUtils
3233

33-
class SearchMembersAdapter: RecyclerView.Adapter<SearchMembersAdapter.SearchMembersViewHolder>() {
34+
class SearchMembersAdapter(
35+
private val listener: SearchMembersAdapterListener
36+
) : RecyclerView.Adapter<SearchMembersAdapter.SearchMembersViewHolder>() {
3437

3538
private var members = mutableListOf<OCMember>()
3639

@@ -47,7 +50,7 @@ class SearchMembersAdapter: RecyclerView.Adapter<SearchMembersAdapter.SearchMemb
4750
val member = members[position]
4851

4952
holder.binding.apply {
50-
val isGroup = member.surname == GROUP_SURNAME
53+
val isGroup = member.type == OCMemberType.GROUP
5154
memberIcon.setImageResource(if (isGroup) R.drawable.ic_group else R.drawable.ic_user)
5255
memberName.text = member.displayName
5356
memberName.contentDescription = holder.itemView.context.getString(
@@ -56,27 +59,30 @@ class SearchMembersAdapter: RecyclerView.Adapter<SearchMembersAdapter.SearchMemb
5659
memberRole.text = if (isGroup) {
5760
holder.itemView.context.getString(R.string.member_type_group)
5861
} else {
59-
if (member.surname == USER_SURNAME) holder.itemView.context.getString(R.string.member_type_user) else member.surname
62+
if (member.surname == OCMemberType.USER_TYPE_STRING) holder.itemView.context.getString(R.string.member_type_user) else member.surname
63+
}
64+
65+
memberItemLayout.setOnClickListener {
66+
listener.onMemberClick(member)
6067
}
6168
}
6269
}
6370

6471
override fun getItemCount(): Int = members.size
6572

6673
fun setMembers(members: List<OCMember>) {
67-
val diffCallback = SpaceMembersDiffUtil(this.members, members)
74+
val diffCallback = SearchMembersDiffUtil(this.members, members)
6875
val diffResult = DiffUtil.calculateDiff(diffCallback)
6976
this.members.clear()
7077
this.members.addAll(members)
7178
diffResult.dispatchUpdatesTo(this)
7279
}
7380

74-
class SearchMembersViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
75-
val binding = MemberItemBinding.bind(itemView)
81+
interface SearchMembersAdapterListener {
82+
fun onMemberClick(member: OCMember)
7683
}
7784

78-
companion object {
79-
private const val USER_SURNAME = "User"
80-
private const val GROUP_SURNAME = "Group"
85+
class SearchMembersViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
86+
val binding = MemberItemBinding.bind(itemView)
8187
}
8288
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* ownCloud Android client application
3+
*
4+
* @author Jorge Aguado Recio
5+
*
6+
* Copyright (C) 2026 ownCloud GmbH.
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU General Public License version 2,
10+
* as published by the Free Software Foundation.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU General Public License
18+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
19+
*/
20+
21+
package com.owncloud.android.presentation.spaces.members
22+
23+
import androidx.recyclerview.widget.DiffUtil
24+
import com.owncloud.android.domain.members.model.OCMember
25+
26+
class SearchMembersDiffUtil(
27+
private val oldList: List<OCMember>,
28+
private val newList: List<OCMember>
29+
) : DiffUtil.Callback() {
30+
override fun getOldListSize(): Int = oldList.size
31+
32+
override fun getNewListSize(): Int = newList.size
33+
34+
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
35+
val oldItem = oldList[oldItemPosition]
36+
val newItem = newList[newItemPosition]
37+
38+
return oldItem.id == newItem.id
39+
}
40+
41+
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
42+
val oldItem = oldList[oldItemPosition]
43+
val newItem = newList[newItemPosition]
44+
45+
return ((oldItem.id == newItem.id) && (oldItem.displayName == newItem.displayName) && (oldItem.surname == newItem.surname))
46+
}
47+
}

owncloudApp/src/main/java/com/owncloud/android/presentation/spaces/members/SpaceMembersActivity.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import android.view.MenuItem
2626
import androidx.fragment.app.transaction
2727
import com.owncloud.android.R
2828
import com.owncloud.android.databinding.MembersActivityBinding
29+
import com.owncloud.android.domain.roles.model.OCRole
2930
import com.owncloud.android.domain.spaces.model.OCSpace
3031
import com.owncloud.android.domain.spaces.model.SpaceMember
3132
import com.owncloud.android.ui.activity.FileActivity
@@ -81,8 +82,8 @@ class SpaceMembersActivity: FileActivity(), SpaceMembersFragment.SpaceMemberFrag
8182
super.onOptionsItemSelected(item)
8283
}
8384

84-
override fun addMember(space: OCSpace, spaceMembers: List<SpaceMember>) {
85-
val addMemberFragment = AddMemberFragment.newInstance(account.name, space, spaceMembers)
85+
override fun addMember(space: OCSpace, spaceMembers: List<SpaceMember>, roles: List<OCRole>) {
86+
val addMemberFragment = AddMemberFragment.newInstance(account.name, space, spaceMembers, roles)
8687
val transaction = supportFragmentManager.beginTransaction()
8788
transaction.apply {
8889
replace(R.id.members_fragment_container, addMemberFragment, TAG_ADD_MEMBER_FRAGMENT)

0 commit comments

Comments
 (0)