From ab9d8b961089a3f124f9500a2e41c70288177839 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 20 Apr 2026 16:00:01 +0200 Subject: [PATCH 01/95] feat: unified share Signed-off-by: alperozturk96 --- gradle/verification-metadata.xml | 5 + material-color-utilities/build.gradle | 1 + ui/build.gradle | 3 + .../common/ui/share/UnifiedShareView.kt | 611 ++++++++++++++ .../common/ui/share/UnifiedShareViewModel.kt | 32 + .../ui/share/model/UnifiedShareCategory.kt | 12 + .../share/model/UnifiedShareDownloadLimit.kt | 13 + .../ui/share/model/UnifiedSharePermission.kt | 30 + .../common/ui/share/model/UnifiedShareType.kt | 31 + .../common/ui/share/model/UnifiedShares.kt | 21 + .../ui/share/previews/UnifiedSharePreviews.kt | 753 ++++++++++++++++++ .../repository/MockUnifiedShareRepository.kt | 105 +++ .../UnifiedShareRemoteRepository.kt | 16 + .../repository/UnifiedShareRepository.kt | 14 + ui/src/main/res/drawable/ic_circles.xml | 16 + ui/src/main/res/drawable/ic_email.xml | 16 + ui/src/main/res/drawable/ic_group.xml | 16 + ui/src/main/res/drawable/ic_link.xml | 16 + ui/src/main/res/drawable/ic_person_add.xml | 9 + ui/src/main/res/drawable/ic_talk.xml | 18 + ui/src/main/res/drawable/ic_user.xml | 16 + 21 files changed, 1754 insertions(+) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareView.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareViewModel.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareCategory.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareDownloadLimit.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedSharePermission.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareType.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShares.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/previews/UnifiedSharePreviews.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockUnifiedShareRepository.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRemoteRepository.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRepository.kt create mode 100644 ui/src/main/res/drawable/ic_circles.xml create mode 100644 ui/src/main/res/drawable/ic_email.xml create mode 100644 ui/src/main/res/drawable/ic_group.xml create mode 100644 ui/src/main/res/drawable/ic_link.xml create mode 100644 ui/src/main/res/drawable/ic_person_add.xml create mode 100644 ui/src/main/res/drawable/ic_talk.xml create mode 100644 ui/src/main/res/drawable/ic_user.xml diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 7a084140..723ae9cb 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -334,6 +334,11 @@ + + + + + diff --git a/material-color-utilities/build.gradle b/material-color-utilities/build.gradle index 83e301dd..119c41b4 100644 --- a/material-color-utilities/build.gradle +++ b/material-color-utilities/build.gradle @@ -1,3 +1,4 @@ + /* * Nextcloud Android Common Library * diff --git a/ui/build.gradle b/ui/build.gradle index 3d1b4d99..29a6c512 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -45,12 +45,15 @@ android { } dependencies { + implementation 'androidx.compose.ui:ui-tooling-preview:1.10.6' + debugImplementation 'androidx.compose.ui:ui-tooling:1.10.6' kapt "org.jetbrains.kotlin:kotlin-metadata-jvm:${kotlinVersion}" implementation(platform("androidx.compose:compose-bom:2026.06.01")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-core") implementation("com.vanniktech:ui:0.10.0") diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareView.kt new file mode 100644 index 00000000..b31eb528 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareView.kt @@ -0,0 +1,611 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Button +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.UnifiedShareCategory +import com.nextcloud.android.common.ui.share.model.UnifiedSharePermission +import com.nextcloud.android.common.ui.share.model.UnifiedShares +import com.nextcloud.android.common.ui.share.repository.MockUnifiedShareRepository + + +// TODO: MOVE TO THE ANDROID: COMMON +// TODO: MAKE LAZY COLUMN +// TODO: EXPOSE ACTIONS, IMPLEMENT VIEWMODEL, REPOSITORY TO FETCH ACTUAL SHARE, INJECT NECESSARY PARAMETERS + +@Composable +fun UnifiedShareView(viewModel: UnifiedShareViewModel) { + var showAddShare by remember { mutableStateOf(false) } + val shares by viewModel.shares.collectAsState() + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + shares.forEachIndexed { index, share -> + val type = when (index) { + 0 -> { + UnifiedSharesListItemType.Top + } + + shares.lastIndex -> { + UnifiedSharesListItemType.Bottom + } + + else -> { + UnifiedSharesListItemType.Mid + } + } + + UnifiedSharesListItem(share, type) + } + + FloatingActionButton( + onClick = { showAddShare = true }, + modifier = Modifier + .align(Alignment.End) + .padding(top = 16.dp) + ) { + Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") + } + + if (showAddShare) { + AddShareBottomSheet("Abc.txt",onDismiss = { showAddShare = false }) + } + } +} + +// TODO: Use like inner tags whenever user add a new people to the search and it should look like User 1, Group 1 etc. + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddShareBottomSheet(filename: String, onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scrollState = rememberScrollState() + + var category by remember { mutableStateOf(UnifiedShareCategory.Invited) } + var permission by remember { mutableStateOf(UnifiedSharePermission.CanView) } + var searchQuery by remember { mutableStateOf("") } + var note by remember { mutableStateOf("") } + + // Toggle states for collapse/expand + var showInvitedSettings by remember { mutableStateOf(false) } + var showAnyoneSettings by remember { mutableStateOf(false) } + + var viewFiles by remember { mutableStateOf(false) } + var editFiles by remember { mutableStateOf(false) } + var createFiles by remember { mutableStateOf(false) } + var deleteFiles by remember { mutableStateOf(false) } + + val availablePermissions = remember { + listOf( + UnifiedSharePermission.CanView, + UnifiedSharePermission.CanEdit, + UnifiedSharePermission.FileDrop, + UnifiedSharePermission.Custom(false, false, false, false) + ) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + ShareBottomSheetHeader(filename) + + ShareCategoryButtonGroup( + selectedCategory = category, + onCategoryChange = { category = it } + ) + + if (category == UnifiedShareCategory.Invited) { + InvitedShareContent( + searchQuery = searchQuery, + onSearchChange = { searchQuery = it }, + permission = permission, + availablePermissions = availablePermissions, + onPermissionChange = { permission = it }, + ) + + CollapsibleSettingsSection( + isExpanded = showInvitedSettings, + onToggle = { showInvitedSettings = !showInvitedSettings } + ) { + InvitedInlineSettings() + } + } else { + AnyoneShareContent( + permission = permission, + availablePermissions = availablePermissions, + onPermissionChange = { permission = it }, + ) + + if (permission is UnifiedSharePermission.Custom) { + SettingsSwitchRow("View files", viewFiles) { viewFiles = it } + SettingsSwitchRow("Edit files", editFiles) { editFiles = it } + SettingsSwitchRow("Create files", createFiles) { createFiles = it } + SettingsSwitchRow("Delete files", deleteFiles) { deleteFiles = it } + } + + CollapsibleSettingsSection( + isExpanded = showAnyoneSettings, + onToggle = { showAnyoneSettings = !showAnyoneSettings } + ) { + AnyoneInlineSettings() + } + } + + NoteToRecipients(note = note, onNoteChange = { note = it }) + + + ShareActionButtons( + category = category, + isSendEnabled = searchQuery.isNotBlank(), + onCopyClick = { /* TODO */ }, + onSendClick = { /* TODO */ } + ) + } + } +} + +@Composable +fun CollapsibleSettingsSection( + isExpanded: Boolean, + onToggle: () -> Unit, + content: @Composable () -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle() } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Settings", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + + AnimatedVisibility(visible = isExpanded) { + Column { + content() + } + } + } +} + +@Composable +fun ShareBottomSheetHeader(filename: String) { + Text( + text = "Share $filename", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareCategoryButtonGroup( + selectedCategory: UnifiedShareCategory, + onCategoryChange: (UnifiedShareCategory) -> Unit +) { + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth() + ) { + UnifiedShareCategory.entries.forEachIndexed { index, option -> + SegmentedButton( + selected = selectedCategory == option, + onClick = { onCategoryChange(option) }, + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = UnifiedShareCategory.entries.size + ) + ) { + Text(option.name) + } + } + } +} + +@Composable +fun InvitedShareContent( + searchQuery: String, + onSearchChange: (String) -> Unit, + permission: UnifiedSharePermission, + availablePermissions: List, + onPermissionChange: (UnifiedSharePermission) -> Unit, + + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Add people") }, + placeholder = { Text("Name, team, email or federated ID") }, + singleLine = true, + shape = RoundedCornerShape(8.dp) + ) + + PermissionDropdown( + label = "Participants", + selectedPermission = permission, + availablePermissions = availablePermissions, + onPermissionChange = onPermissionChange + ) + } +} + +@Composable +fun NoteToRecipients( + note: String, + onNoteChange: (String) -> Unit +) { + OutlinedTextField( + value = note, + onValueChange = onNoteChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Note to recipients") }, + shape = RoundedCornerShape(8.dp) + ) +} + +@Composable +fun AnyoneShareContent( + permission: UnifiedSharePermission, + availablePermissions: List, + onPermissionChange: (UnifiedSharePermission) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + PermissionDropdown( + label = "Anyone with the link", + selectedPermission = permission, + availablePermissions = availablePermissions, + onPermissionChange = onPermissionChange + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PermissionDropdown( + label: String, + selectedPermission: UnifiedSharePermission, + availablePermissions: List, + onPermissionChange: (UnifiedSharePermission) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = selectedPermission.getText(), + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + availablePermissions.forEach { option -> + DropdownMenuItem( + text = { Text(option.getText()) }, + onClick = { + onPermissionChange(option) + expanded = false + } + ) + } + } + } +} + +@Composable +private fun InvitedInlineSettings() { + var shareWithOthers by remember { mutableStateOf(false) } + var editFile by remember { mutableStateOf(false) } + var hasExpiration by remember { mutableStateOf(false) } + var hideDownload by remember { mutableStateOf(false) } + + Column { + SettingsSwitchRow("Share with others", shareWithOthers) { shareWithOthers = it } + SettingsSwitchRow("Edit file", editFile) { editFile = it } + SettingsSwitchRow("Expiration date", hasExpiration) { hasExpiration = it } + SettingsSwitchRow("Hide download and sync options", hideDownload) { hideDownload = it } + } +} + +@Composable +private fun AnyoneInlineSettings() { + var hasPassword by remember { mutableStateOf(false) } + var hasExpiration by remember { mutableStateOf(false) } + var limitDownloads by remember { mutableStateOf(false) } + + var hideDownloads by remember { mutableStateOf(false) } + var videoVerification by remember { mutableStateOf(false) } + var showFilesInGridView by remember { mutableStateOf(false) } + + Column { + OutlinedTextField( + value = "", + onValueChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + label = { Text("Label") }, + placeholder = { Text("Optional name for this link") }, + singleLine = true + ) + + SettingsSwitchRow("Expiration date", hasExpiration) { hasExpiration = it } + SettingsSwitchRow("Password", hasPassword) { hasPassword = it } + SettingsSwitchRow("Limit downloads", limitDownloads) { limitDownloads = it } + + SettingsSwitchRow("Hide downloads", hideDownloads) { hideDownloads = it } + SettingsSwitchRow("Video verification", videoVerification) { videoVerification = it } + SettingsSwitchRow("Show files in grid view", showFilesInGridView) { showFilesInGridView = it } + + } +} + +@Composable +fun SettingsSwitchRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = label, style = MaterialTheme.typography.bodyLarge) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +fun ShareActionButtons( + category: UnifiedShareCategory, + isSendEnabled: Boolean, + onCopyClick: () -> Unit, + onSendClick: () -> Unit +) { + Row(modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp)) { + if (category == UnifiedShareCategory.Invited) { + FilledTonalButton( + onClick = onCopyClick, + modifier = Modifier.weight(1f) + ) { + Text("Copy link") + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = onSendClick, + modifier = Modifier.weight(1f), + enabled = isSendEnabled // Disabled if search query is empty + ) { + Text("Send") + } + } else { + // For "Anyone" (Public link), usually just one big action to create/copy + Button( + onClick = onCopyClick, + modifier = Modifier.fillMaxWidth() + ) { + Text("Create public link") + } + } + } +} + +enum class UnifiedSharesListItemType { + Top, Mid, Bottom; + + @Composable + fun getShape(): RoundedCornerShape { + return when (this) { + Top -> RoundedCornerShape(12.dp, 12.dp, 4.dp, 4.dp) + Mid -> RoundedCornerShape(4.dp, 4.dp, 4.dp, 4.dp) + Bottom -> RoundedCornerShape(4.dp, 4.dp, 12.dp, 12.dp) + } + } +} + +// NOTE: To just create a public link anyone tab + just send DOES SAME THING +@Composable +fun UnifiedSharesListItem(share: UnifiedShares, type: UnifiedSharesListItemType) { + var showContextMenu by remember { mutableStateOf(false) } + var showDetailSheet by remember { mutableStateOf(false) } + val haptics = LocalHapticFeedback.current + + ListItem( + modifier = Modifier + .fillMaxWidth() + .clip(type.getShape()) + .combinedClickable( + onClick = { showDetailSheet = true }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + showContextMenu = true + }, + ) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + leadingContent = { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + share.type.Icon() + } + }, + headlineContent = { + Text( + text = share.label, + style = MaterialTheme.typography.titleSmall + ) + }, + supportingContent = { + Text( + text = share.permission.getText(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingContent = { + Box { + IconButton(onClick = { showContextMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "More options") + } + + DropdownMenu( + expanded = showContextMenu, + onDismissRequest = { showContextMenu = false } + ) { + DropdownMenuItem( + text = { Text("Edit") }, + onClick = { + showContextMenu = false + showDetailSheet = true + } + ) + + DropdownMenuItem( + text = { Text("Send email") }, + onClick = { showContextMenu = false } + ) + + HorizontalDivider() + + DropdownMenuItem( + text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, + onClick = { showContextMenu = false } + ) + } + } + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent + ) + ) + + // TODO: USE EXISTING SHARE DETAILS + if (showDetailSheet) { + AddShareBottomSheet( + filename = share.label, + onDismiss = { showDetailSheet = false } + ) + } +} + +fun ComposeView.setupUnifiedShare(colorScheme: ColorScheme) { + // TODO: REPLACE + val viewModel = UnifiedShareViewModel(repository = MockUnifiedShareRepository()) + + setContent { + MaterialTheme( + colorScheme = colorScheme, + content = { + UnifiedShareView(viewModel) + } + ) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareViewModel.kt new file mode 100644 index 00000000..33310cb1 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareViewModel.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.android.common.ui.share.model.UnifiedShares +import com.nextcloud.android.common.ui.share.repository.UnifiedShareRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class UnifiedShareViewModel(private val repository: UnifiedShareRepository): ViewModel() { + private val _shares = MutableStateFlow>(emptyList()) + val shares: StateFlow> = _shares + + init { + viewModelScope.launch(Dispatchers.IO) { + val shares = repository.fetchShares() + _shares.update { + shares + } + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareCategory.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareCategory.kt new file mode 100644 index 00000000..bf9775cf --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareCategory.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model + +enum class UnifiedShareCategory { + Invited, Anyone +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareDownloadLimit.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareDownloadLimit.kt new file mode 100644 index 00000000..d2d0ce5a --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareDownloadLimit.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model + +data class UnifiedShareDownloadLimit( + val limit: Int, + val downloadCount: Int +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedSharePermission.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedSharePermission.kt new file mode 100644 index 00000000..46150894 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedSharePermission.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model + +sealed class UnifiedSharePermission { + // file drop only for folder + data object FileDrop : UnifiedSharePermission() + + data object CanView : UnifiedSharePermission() + data object CanEdit : UnifiedSharePermission() + + // create only for folder + data class Custom(val read: Boolean, val edit: Boolean, val delete: Boolean, val create: Boolean) : + UnifiedSharePermission() + + fun getText(): String { + return when(this) { + FileDrop -> "File drop" + CanView -> "Can view" + CanEdit -> "Can edit" + is Custom -> "Custom permissions" + } + } +} + diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareType.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareType.kt new file mode 100644 index 00000000..f4c1139f --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareType.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import com.nextcloud.android.common.ui.R + +enum class UnifiedShareType { + InternalUser, InternalGroup, InternalLink, ExternalLink, ExternalFederated, ExternalMail; + + @Composable + fun Icon() { + val iconId = when (this) { + InternalUser -> R.drawable.ic_user + InternalGroup -> R.drawable.ic_group + InternalLink -> R.drawable.ic_email + ExternalLink -> R.drawable.ic_link + ExternalFederated -> R.drawable.ic_group + ExternalMail -> R.drawable.ic_email + } + + Icon(painterResource(iconId), contentDescription = "share type icon") + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShares.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShares.kt new file mode 100644 index 00000000..3b287701 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShares.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model + +data class UnifiedShares( + val id: Int, + val password: String, + val note: String, + val limit: UnifiedShareDownloadLimit, + val expirationDate: Int, + val permission: UnifiedSharePermission, + val label: String, + val sharedTo: String, + val type: UnifiedShareType, + val category: UnifiedShareCategory, +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/UnifiedSharePreviews.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/UnifiedSharePreviews.kt new file mode 100644 index 00000000..6671d6e3 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/UnifiedSharePreviews.kt @@ -0,0 +1,753 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.previews + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.share.AnyoneShareContent +import com.nextcloud.android.common.ui.share.CollapsibleSettingsSection +import com.nextcloud.android.common.ui.share.InvitedShareContent +import com.nextcloud.android.common.ui.share.NoteToRecipients +import com.nextcloud.android.common.ui.share.PermissionDropdown +import com.nextcloud.android.common.ui.share.SettingsSwitchRow +import com.nextcloud.android.common.ui.share.ShareActionButtons +import com.nextcloud.android.common.ui.share.ShareBottomSheetHeader +import com.nextcloud.android.common.ui.share.ShareCategoryButtonGroup +import com.nextcloud.android.common.ui.share.UnifiedShareView +import com.nextcloud.android.common.ui.share.UnifiedShareViewModel +import com.nextcloud.android.common.ui.share.UnifiedSharesListItem +import com.nextcloud.android.common.ui.share.UnifiedSharesListItemType +import com.nextcloud.android.common.ui.share.model.UnifiedShareCategory +import com.nextcloud.android.common.ui.share.model.UnifiedShareDownloadLimit +import com.nextcloud.android.common.ui.share.model.UnifiedSharePermission +import com.nextcloud.android.common.ui.share.model.UnifiedShareType +import com.nextcloud.android.common.ui.share.model.UnifiedShares +import com.nextcloud.android.common.ui.share.repository.MockUnifiedShareRepository + +@Composable +private fun PreviewTheme( + darkTheme: Boolean = false, + content: @Composable () -> Unit +) { + MaterialTheme { + Surface(content = content) + } +} + +@Preview(name = "UnifiedShareView – light", showBackground = true) +@Composable +fun Preview_UnifiedShareView_Light() { + PreviewTheme { + UnifiedShareView(viewModel = UnifiedShareViewModel(MockUnifiedShareRepository())) + } +} + +@Preview(name = "UnifiedShareView – dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun Preview_UnifiedShareView_Dark() { + PreviewTheme(darkTheme = true) { + UnifiedShareView(viewModel = UnifiedShareViewModel(MockUnifiedShareRepository())) + } +} + +@Preview(name = "ListItem – Top", showBackground = true, group = "List Item") +@Composable +fun Preview_UnifiedSharesListItem_Top() { + PreviewTheme { + Column(Modifier.padding(8.dp)) { + UnifiedSharesListItemPreviewHelper( + share = previewUserShare(), + type = UnifiedSharesListItemType.Top + ) + } + } +} + +@Preview(name = "ListItem – Mid", showBackground = true, group = "List Item") +@Composable +fun Preview_UnifiedSharesListItem_Mid() { + PreviewTheme { + Column(Modifier.padding(8.dp)) { + UnifiedSharesListItemPreviewHelper( + share = previewGroupShare(), + type = UnifiedSharesListItemType.Mid + ) + } + } +} + +@Preview(name = "ListItem – Bottom", showBackground = true, group = "List Item") +@Composable +fun Preview_UnifiedSharesListItem_Bottom() { + PreviewTheme { + Column(Modifier.padding(8.dp)) { + UnifiedSharesListItemPreviewHelper( + share = previewPublicLinkShare(), + type = UnifiedSharesListItemType.Bottom + ) + } + } +} + +@Preview(name = "ListItem – Single (all three stacked)", showBackground = true, group = "List Item") +@Composable +fun Preview_UnifiedSharesListItem_AllTypes() { + PreviewTheme { + Column(Modifier.padding(8.dp)) { + UnifiedSharesListItemPreviewHelper(previewUserShare(), UnifiedSharesListItemType.Top) + UnifiedSharesListItemPreviewHelper(previewGroupShare(), UnifiedSharesListItemType.Mid) + UnifiedSharesListItemPreviewHelper(previewPublicLinkShare(), UnifiedSharesListItemType.Bottom) + } + } +} + +@Composable +private fun UnifiedSharesListItemPreviewHelper(share: UnifiedShares, type: UnifiedSharesListItemType) { + UnifiedSharesListItem(share = share, type = type) +} + +@Preview(name = "ListItem – CanView permission", showBackground = true, group = "Permissions") +@Composable +fun Preview_ListItem_CanView() { + PreviewTheme { + Column(Modifier.padding(8.dp)) { + UnifiedSharesListItemPreviewHelper( + share = previewUserShare(permission = UnifiedSharePermission.CanView), + type = UnifiedSharesListItemType.Top + ) + } + } +} + +@Preview(name = "ListItem – CanEdit permission", showBackground = true, group = "Permissions") +@Composable +fun Preview_ListItem_CanEdit() { + PreviewTheme { + Column(Modifier.padding(8.dp)) { + UnifiedSharesListItemPreviewHelper( + share = previewUserShare(permission = UnifiedSharePermission.CanEdit), + type = UnifiedSharesListItemType.Top + ) + } + } +} + +@Preview(name = "ListItem – FileDrop permission", showBackground = true, group = "Permissions") +@Composable +fun Preview_ListItem_FileDrop() { + PreviewTheme { + Column(Modifier.padding(8.dp)) { + UnifiedSharesListItemPreviewHelper( + share = previewUserShare(permission = UnifiedSharePermission.FileDrop), + type = UnifiedSharesListItemType.Top + ) + } + } +} + +@Preview(name = "ListItem – Custom permission", showBackground = true, group = "Permissions") +@Composable +fun Preview_ListItem_CustomPermission() { + PreviewTheme { + Column(Modifier.padding(8.dp)) { + UnifiedSharesListItemPreviewHelper( + share = previewUserShare( + permission = UnifiedSharePermission.Custom( + true, + false, + true, + false + ) + ), + type = UnifiedSharesListItemType.Top + ) + } + } +} + +@Preview( + name = "BottomSheet – Invited / default", + showBackground = true, + heightDp = 900, + group = "Bottom Sheet" +) +@Composable +fun Preview_AddShareBottomSheet_Invited() { + PreviewTheme { + AddShareBottomSheetContentPreview( + category = UnifiedShareCategory.Invited, + permission = UnifiedSharePermission.CanView, + searchQuery = "", + note = "", + showSettings = false + ) + } +} + +@Preview( + name = "BottomSheet – Invited / search filled", + showBackground = true, + heightDp = 900, + group = "Bottom Sheet" +) +@Composable +fun Preview_AddShareBottomSheet_InvitedWithSearch() { + PreviewTheme { + AddShareBottomSheetContentPreview( + category = UnifiedShareCategory.Invited, + permission = UnifiedSharePermission.CanEdit, + searchQuery = "alice@nextcloud.example", + note = "Here are the Q2 reports!", + showSettings = false + ) + } +} + +@Preview( + name = "BottomSheet – Invited / settings expanded", + showBackground = true, + heightDp = 1100, + group = "Bottom Sheet" +) +@Composable +fun Preview_AddShareBottomSheet_InvitedSettingsExpanded() { + PreviewTheme { + AddShareBottomSheetContentPreview( + category = UnifiedShareCategory.Invited, + permission = UnifiedSharePermission.CanView, + searchQuery = "bob", + note = "", + showSettings = true + ) + } +} + +@Preview( + name = "BottomSheet – Anyone / default", + showBackground = true, + heightDp = 900, + group = "Bottom Sheet" +) +@Composable +fun Preview_AddShareBottomSheet_Anyone() { + PreviewTheme { + AddShareBottomSheetContentPreview( + category = UnifiedShareCategory.Anyone, + permission = UnifiedSharePermission.CanView, + searchQuery = "", + note = "", + showSettings = false + ) + } +} + +@Preview( + name = "BottomSheet – Anyone / Custom permission (extra switches)", + showBackground = true, + heightDp = 1100, + group = "Bottom Sheet" +) +@Composable +fun Preview_AddShareBottomSheet_AnyoneCustomPermission() { + PreviewTheme { + AddShareBottomSheetContentPreview( + category = UnifiedShareCategory.Anyone, + permission = UnifiedSharePermission.Custom( + true, + false, + false, + false + ), + searchQuery = "", + note = "", + showSettings = false + ) + } +} + +@Preview( + name = "BottomSheet – Anyone / settings expanded", + showBackground = true, + heightDp = 1200, + group = "Bottom Sheet" +) +@Composable +fun Preview_AddShareBottomSheet_AnyoneSettingsExpanded() { + PreviewTheme { + AddShareBottomSheetContentPreview( + category = UnifiedShareCategory.Anyone, + permission = UnifiedSharePermission.CanView, + searchQuery = "", + note = "Public note", + showSettings = true + ) + } +} + +@Preview(name = "CategoryButtons – Invited selected", showBackground = true, group = "Category Buttons") +@Composable +fun Preview_ShareCategoryButtonGroup_Invited() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + ShareCategoryButtonGroup( + selectedCategory = UnifiedShareCategory.Invited, + onCategoryChange = {} + ) + } + } +} + +@Preview(name = "CategoryButtons – Anyone selected", showBackground = true, group = "Category Buttons") +@Composable +fun Preview_ShareCategoryButtonGroup_Anyone() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + ShareCategoryButtonGroup( + selectedCategory = UnifiedShareCategory.Anyone, + onCategoryChange = {} + ) + } + } +} + +@Preview(name = "ActionButtons – Invited / Send disabled", showBackground = true, group = "Action Buttons") +@Composable +fun Preview_ShareActionButtons_InvitedSendDisabled() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + ShareActionButtons( + category = UnifiedShareCategory.Invited, + isSendEnabled = false, + onCopyClick = {}, + onSendClick = {} + ) + } + } +} + +@Preview(name = "ActionButtons – Invited / Send enabled", showBackground = true, group = "Action Buttons") +@Composable +fun Preview_ShareActionButtons_InvitedSendEnabled() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + ShareActionButtons( + category = UnifiedShareCategory.Invited, + isSendEnabled = true, + onCopyClick = {}, + onSendClick = {} + ) + } + } +} + +@Preview(name = "ActionButtons – Anyone", showBackground = true, group = "Action Buttons") +@Composable +fun Preview_ShareActionButtons_Anyone() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + ShareActionButtons( + category = UnifiedShareCategory.Anyone, + isSendEnabled = false, + onCopyClick = {}, + onSendClick = {} + ) + } + } +} + +@Preview(name = "CollapsibleSettings – collapsed", showBackground = true, group = "Settings Section") +@Composable +fun Preview_CollapsibleSettingsSection_Collapsed() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + CollapsibleSettingsSection(isExpanded = false, onToggle = {}) { + InvitedInlineSettingsPreview() + } + } + } +} + +@Preview(name = "CollapsibleSettings – expanded (Invited)", showBackground = true, group = "Settings Section") +@Composable +fun Preview_CollapsibleSettingsSection_ExpandedInvited() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + CollapsibleSettingsSection(isExpanded = true, onToggle = {}) { + InvitedInlineSettingsPreview() + } + } + } +} + +@Preview(name = "CollapsibleSettings – expanded (Anyone)", showBackground = true, group = "Settings Section") +@Composable +fun Preview_CollapsibleSettingsSection_ExpandedAnyone() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + CollapsibleSettingsSection(isExpanded = true, onToggle = {}) { + AnyoneInlineSettingsPreview() + } + } + } +} + +private val allPermissions = listOf( + UnifiedSharePermission.CanView, + UnifiedSharePermission.CanEdit, + UnifiedSharePermission.FileDrop, + UnifiedSharePermission.Custom(false, false, false, false) +) + +@Preview(name = "PermissionDropdown – CanView", showBackground = true, group = "Permission Dropdown") +@Composable +fun Preview_PermissionDropdown_CanView() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + PermissionDropdown( + label = "Participants", + selectedPermission = UnifiedSharePermission.CanView, + availablePermissions = allPermissions, + onPermissionChange = {} + ) + } + } +} + +@Preview(name = "PermissionDropdown – CanEdit", showBackground = true, group = "Permission Dropdown") +@Composable +fun Preview_PermissionDropdown_CanEdit() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + PermissionDropdown( + label = "Anyone with the link", + selectedPermission = UnifiedSharePermission.CanEdit, + availablePermissions = allPermissions, + onPermissionChange = {} + ) + } + } +} + +@Preview(name = "PermissionDropdown – FileDrop", showBackground = true, group = "Permission Dropdown") +@Composable +fun Preview_PermissionDropdown_FileDrop() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + PermissionDropdown( + label = "Anyone with the link", + selectedPermission = UnifiedSharePermission.FileDrop, + availablePermissions = allPermissions, + onPermissionChange = {} + ) + } + } +} + +@Preview(name = "PermissionDropdown – Custom", showBackground = true, group = "Permission Dropdown") +@Composable +fun Preview_PermissionDropdown_Custom() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + PermissionDropdown( + label = "Participants", + selectedPermission = UnifiedSharePermission.Custom(true, false, false, false), + availablePermissions = allPermissions, + onPermissionChange = {} + ) + } + } +} + +@Preview(name = "NoteToRecipients – empty", showBackground = true, group = "Note") +@Composable +fun Preview_NoteToRecipients_Empty() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + NoteToRecipients(note = "", onNoteChange = {}) + } + } +} + +@Preview(name = "NoteToRecipients – with text", showBackground = true, group = "Note") +@Composable +fun Preview_NoteToRecipients_WithText() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + NoteToRecipients(note = "Please review by end of week!", onNoteChange = {}) + } + } +} + +@Preview(name = "InvitedShareContent – empty query", showBackground = true, group = "Invited Content") +@Composable +fun Preview_InvitedShareContent_EmptyQuery() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + InvitedShareContent( + searchQuery = "", + onSearchChange = {}, + permission = UnifiedSharePermission.CanView, + availablePermissions = allPermissions, + onPermissionChange = {} + ) + } + } +} + +@Preview(name = "InvitedShareContent – with query", showBackground = true, group = "Invited Content") +@Composable +fun Preview_InvitedShareContent_WithQuery() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + InvitedShareContent( + searchQuery = "carol@company.org", + onSearchChange = {}, + permission = UnifiedSharePermission.CanEdit, + availablePermissions = allPermissions, + onPermissionChange = {} + ) + } + } +} + +@Preview(name = "AnyoneShareContent – CanView", showBackground = true, group = "Anyone Content") +@Composable +fun Preview_AnyoneShareContent_CanView() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + AnyoneShareContent( + permission = UnifiedSharePermission.CanView, + availablePermissions = allPermissions, + onPermissionChange = {} + ) + } + } +} + +@Preview(name = "AnyoneShareContent – FileDrop", showBackground = true, group = "Anyone Content") +@Composable +fun Preview_AnyoneShareContent_FileDrop() { + PreviewTheme { + Box(Modifier.padding(16.dp)) { + AnyoneShareContent( + permission = UnifiedSharePermission.FileDrop, + availablePermissions = allPermissions, + onPermissionChange = {} + ) + } + } +} + +@Preview(name = "SwitchRow – off", showBackground = true, group = "Switch Row") +@Composable +fun Preview_SettingsSwitchRow_Off() { + PreviewTheme { + Box(Modifier.padding(horizontal = 16.dp)) { + SettingsSwitchRow(label = "Hide download and sync options", checked = false, onCheckedChange = {}) + } + } +} + +@Preview(name = "SwitchRow – on", showBackground = true, group = "Switch Row") +@Composable +fun Preview_SettingsSwitchRow_On() { + PreviewTheme { + Box(Modifier.padding(horizontal = 16.dp)) { + SettingsSwitchRow(label = "Expiration date", checked = true, onCheckedChange = {}) + } + } +} + +@Preview(name = "Item shape – all types", showBackground = true, group = "Shape") +@Composable +fun Preview_ItemShapes_AllTypes() { + PreviewTheme { + Column(Modifier.padding(8.dp)) { + UnifiedSharesListItemType.entries.forEach { type -> + UnifiedSharesListItemPreviewHelper(share = previewUserShare(), type = type) + } + } + } +} + +@Preview( + name = "UnifiedShareView – tablet landscape", + showBackground = true, + widthDp = 840, + heightDp = 600 +) +@Composable +fun Preview_UnifiedShareView_Tablet() { + PreviewTheme { + UnifiedShareView(viewModel = UnifiedShareViewModel(MockUnifiedShareRepository())) + } +} + +@Composable +private fun AddShareBottomSheetContentPreview( + category: UnifiedShareCategory, + permission: UnifiedSharePermission, + searchQuery: String, + note: String, + showSettings: Boolean +) { + val availablePermissions = listOf( + UnifiedSharePermission.CanView, + UnifiedSharePermission.CanEdit, + UnifiedSharePermission.FileDrop, + UnifiedSharePermission.Custom(false, false, false, false) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + ShareBottomSheetHeader(filename = "Abc.txt") + + ShareCategoryButtonGroup( + selectedCategory = category, + onCategoryChange = {} + ) + + if (category == UnifiedShareCategory.Invited) { + InvitedShareContent( + searchQuery = searchQuery, + onSearchChange = {}, + permission = permission, + availablePermissions = availablePermissions, + onPermissionChange = {} + ) + CollapsibleSettingsSection( + isExpanded = showSettings, + onToggle = {} + ) { + InvitedInlineSettingsPreview() + } + } else { + AnyoneShareContent( + permission = permission, + availablePermissions = availablePermissions, + onPermissionChange = {} + ) + if (permission is UnifiedSharePermission.Custom) { + SettingsSwitchRow("View files", false) {} + SettingsSwitchRow("Edit files", false) {} + SettingsSwitchRow("Create files", false) {} + SettingsSwitchRow("Delete files", false) {} + } + CollapsibleSettingsSection( + isExpanded = showSettings, + onToggle = {} + ) { + AnyoneInlineSettingsPreview() + } + } + + NoteToRecipients(note = note, onNoteChange = {}) + + ShareActionButtons( + category = category, + isSendEnabled = searchQuery.isNotBlank(), + onCopyClick = {}, + onSendClick = {} + ) + } +} + +@Composable +private fun InvitedInlineSettingsPreview() { + Column { + SettingsSwitchRow("Share with others", false) {} + SettingsSwitchRow("Edit file", false) {} + SettingsSwitchRow("Expiration date", true) {} + SettingsSwitchRow("Hide download and sync options", false) {} + } +} + +@Composable +private fun AnyoneInlineSettingsPreview() { + Column { + OutlinedTextField( + value = "Public reports link", + onValueChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + label = { Text("Label") }, + placeholder = { Text("Optional name for this link") }, + singleLine = true + ) + SettingsSwitchRow("Expiration date", false) {} + SettingsSwitchRow("Password", true) {} + SettingsSwitchRow("Limit downloads", false) {} + SettingsSwitchRow("Hide downloads", false) {} + SettingsSwitchRow("Video verification", false) {} + SettingsSwitchRow("Show files in grid view", true) {} + } +} + +private fun previewUserShare( + permission: UnifiedSharePermission = UnifiedSharePermission.CanView +) = UnifiedShares( + label = "Alice Smith", + type = UnifiedShareType.InternalUser, + permission = permission, + expirationDate = 0, + sharedTo = "", + category = UnifiedShareCategory.Invited, + id = 1, + password = "", + note = "", + limit = UnifiedShareDownloadLimit(0, 0), +) + +private fun previewGroupShare( + permission: UnifiedSharePermission = UnifiedSharePermission.CanEdit +) = UnifiedShares( + label = "Design Team", + type = UnifiedShareType.InternalGroup, + permission = permission, + expirationDate = 0, + sharedTo = "", + category = UnifiedShareCategory.Invited, + id = 1, + password = "", + note = "", + limit = UnifiedShareDownloadLimit(0, 0), +) + +private fun previewPublicLinkShare( + permission: UnifiedSharePermission = UnifiedSharePermission.FileDrop +) = UnifiedShares( + label = "Public link", + type = UnifiedShareType.ExternalLink, + permission = permission, + expirationDate = 0, + sharedTo = "", + category = UnifiedShareCategory.Invited, + id = 1, + password = "", + note = "", + limit = UnifiedShareDownloadLimit(0, 0), +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockUnifiedShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockUnifiedShareRepository.kt new file mode 100644 index 00000000..85396398 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockUnifiedShareRepository.kt @@ -0,0 +1,105 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.repository + +import com.nextcloud.android.common.ui.share.model.UnifiedShareCategory +import com.nextcloud.android.common.ui.share.model.UnifiedShareDownloadLimit +import com.nextcloud.android.common.ui.share.model.UnifiedSharePermission +import com.nextcloud.android.common.ui.share.model.UnifiedShareType +import com.nextcloud.android.common.ui.share.model.UnifiedShares + +class MockUnifiedShareRepository: UnifiedShareRepository { + override suspend fun fetchShares(): List { + return listOf( + UnifiedShares( + id = 1, + password = "", + note = "Design review – please check latest changes", + limit = UnifiedShareDownloadLimit( + limit = 100, + downloadCount = 12 + ), + expirationDate = 0, + permission = UnifiedSharePermission.CanView, + label = "Alice Johnson", + sharedTo = "alice@company.com", + type = UnifiedShareType.InternalUser, + category = UnifiedShareCategory.Invited + ), + + UnifiedShares( + id = 2, + password = "", + note = "", + limit = UnifiedShareDownloadLimit( + limit = 0, + downloadCount = 0 + ), + expirationDate = 0, + permission = UnifiedSharePermission.CanEdit, + label = "Marketing Team", + sharedTo = "marketing", + type = UnifiedShareType.InternalGroup, + category = UnifiedShareCategory.Invited + ), + + UnifiedShares( + id = 3, + password = "1234", + note = "Public link for client review", + limit = UnifiedShareDownloadLimit( + limit = 50, + downloadCount = 5 + ), + expirationDate = 1710000000, + permission = UnifiedSharePermission.Custom( + read = true, + edit = false, + delete = false, + create = false + ), + label = "Public Link", + sharedTo = "https://nextcloud.com/s/abc123", + type = UnifiedShareType.InternalLink, + category = UnifiedShareCategory.Anyone + ), + + UnifiedShares( + id = 4, + password = "", + note = "External partner access", + limit = UnifiedShareDownloadLimit( + limit = 20, + downloadCount = 2 + ), + expirationDate = 0, + permission = UnifiedSharePermission.CanView, + label = "John External", + sharedTo = "john@external.com", + type = UnifiedShareType.ExternalMail, + category = UnifiedShareCategory.Anyone + ), + + UnifiedShares( + id = 5, + password = "", + note = "Federated sharing with partner instance", + limit = UnifiedShareDownloadLimit( + limit = 0, + downloadCount = 0 + ), + expirationDate = 0, + permission = UnifiedSharePermission.FileDrop, + label = "Partner Cloud", + sharedTo = "partner@nextcloud.org", + type = UnifiedShareType.ExternalFederated, + category = UnifiedShareCategory.Anyone + ) + ) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRemoteRepository.kt new file mode 100644 index 00000000..e2d9beab --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRemoteRepository.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.repository + +import com.nextcloud.android.common.ui.share.model.UnifiedShares + +class UnifiedShareRemoteRepository: UnifiedShareRepository { + override suspend fun fetchShares(): List { + TODO("Not yet implemented") + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRepository.kt new file mode 100644 index 00000000..09596cdb --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRepository.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.repository + +import com.nextcloud.android.common.ui.share.model.UnifiedShares + +interface UnifiedShareRepository { + suspend fun fetchShares(): List +} diff --git a/ui/src/main/res/drawable/ic_circles.xml b/ui/src/main/res/drawable/ic_circles.xml new file mode 100644 index 00000000..5b07aff7 --- /dev/null +++ b/ui/src/main/res/drawable/ic_circles.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui/src/main/res/drawable/ic_email.xml b/ui/src/main/res/drawable/ic_email.xml new file mode 100644 index 00000000..3319f67e --- /dev/null +++ b/ui/src/main/res/drawable/ic_email.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui/src/main/res/drawable/ic_group.xml b/ui/src/main/res/drawable/ic_group.xml new file mode 100644 index 00000000..e68f08e7 --- /dev/null +++ b/ui/src/main/res/drawable/ic_group.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui/src/main/res/drawable/ic_link.xml b/ui/src/main/res/drawable/ic_link.xml new file mode 100644 index 00000000..3cb49187 --- /dev/null +++ b/ui/src/main/res/drawable/ic_link.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui/src/main/res/drawable/ic_person_add.xml b/ui/src/main/res/drawable/ic_person_add.xml new file mode 100644 index 00000000..db9f2514 --- /dev/null +++ b/ui/src/main/res/drawable/ic_person_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/main/res/drawable/ic_talk.xml b/ui/src/main/res/drawable/ic_talk.xml new file mode 100644 index 00000000..e55ac5d9 --- /dev/null +++ b/ui/src/main/res/drawable/ic_talk.xml @@ -0,0 +1,18 @@ + + + + diff --git a/ui/src/main/res/drawable/ic_user.xml b/ui/src/main/res/drawable/ic_user.xml new file mode 100644 index 00000000..d6267b2f --- /dev/null +++ b/ui/src/main/res/drawable/ic_user.xml @@ -0,0 +1,16 @@ + + + + From bfa5cbb64743e2fe0252068168343281fed3ea26 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 09:52:07 +0200 Subject: [PATCH 02/95] add api models Signed-off-by: alperozturk96 --- .../android/common/ui/network/ApiResult.kt | 13 +++ .../android/common/ui/network/OcsResponse.kt | 38 ++++++++ .../{UnifiedShareView.kt => ShareView.kt} | 13 ++- ...iedShareViewModel.kt => ShareViewModel.kt} | 6 +- .../model/api/create/CreateShareRequest.kt | 23 +++++ .../model/api/create/ShareDataResponse.kt | 27 ++++++ .../common/ui/share/model/api/owner/Owner.kt | 21 +++++ .../model/api/recipients/ShareRecipients.kt | 29 ++++++ .../model/api/update/UpdateShareRequest.kt | 32 +++++++ .../ui/share/model/api/user/ShareUser.kt | 20 ++++ .../model/{ => ui}/UnifiedShareCategory.kt | 2 +- .../{ => ui}/UnifiedShareDownloadLimit.kt | 2 +- .../model/{ => ui}/UnifiedSharePermission.kt | 2 +- .../share/model/{ => ui}/UnifiedShareType.kt | 2 +- .../ui/share/model/{ => ui}/UnifiedShares.kt | 2 +- ...ifiedSharePreviews.kt => SharePreviews.kt} | 21 ++--- ...reRepository.kt => MockShareRepository.kt} | 12 +-- .../share/repository/ShareRemoteRepository.kt | 91 +++++++++++++++++++ .../ui/share/repository/ShareRepository.kt | 37 ++++++++ .../UnifiedShareRemoteRepository.kt | 16 ---- .../repository/UnifiedShareRepository.kt | 14 --- 21 files changed, 361 insertions(+), 62 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/ApiResult.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/OcsResponse.kt rename ui/src/main/java/com/nextcloud/android/common/ui/share/{UnifiedShareView.kt => ShareView.kt} (97%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/{UnifiedShareViewModel.kt => ShareViewModel.kt} (77%) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/CreateShareRequest.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/ShareRecipients.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/update/UpdateShareRequest.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/ShareUser.kt rename ui/src/main/java/com/nextcloud/android/common/ui/share/model/{ => ui}/UnifiedShareCategory.kt (79%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/model/{ => ui}/UnifiedShareDownloadLimit.kt (81%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/model/{ => ui}/UnifiedSharePermission.kt (93%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/model/{ => ui}/UnifiedShareType.kt (94%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/model/{ => ui}/UnifiedShares.kt (89%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/previews/{UnifiedSharePreviews.kt => SharePreviews.kt} (96%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/repository/{MockUnifiedShareRepository.kt => MockShareRepository.kt} (89%) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRemoteRepository.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRepository.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/ApiResult.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/ApiResult.kt new file mode 100644 index 00000000..1827178f --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/ApiResult.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network + +sealed class ApiResult { + data class Success(val data: T) : ApiResult() + data class Error(val error: OcsResponse) : ApiResult() +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/OcsResponse.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/OcsResponse.kt new file mode 100644 index 00000000..1dc0e7d9 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/OcsResponse.kt @@ -0,0 +1,38 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OcsResponse( + val ocs: Ocs +) + +@Serializable +data class Ocs( + val meta: Meta, + val data: T +) + +@Serializable +data class Meta( + val status: String, + + @SerialName("statuscode") + val statusCode: Int, + + val message: String, + + @SerialName("totalitems") + val totalItems: String, + + @SerialName("itemsperpage") + val itemsPerPage: String +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt similarity index 97% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareView.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index b31eb528..15e0c6a5 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -29,7 +29,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Button import androidx.compose.material3.ColorScheme import androidx.compose.material3.DropdownMenu @@ -69,10 +68,10 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R -import com.nextcloud.android.common.ui.share.model.UnifiedShareCategory -import com.nextcloud.android.common.ui.share.model.UnifiedSharePermission -import com.nextcloud.android.common.ui.share.model.UnifiedShares -import com.nextcloud.android.common.ui.share.repository.MockUnifiedShareRepository +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory +import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShares +import com.nextcloud.android.common.ui.share.repository.MockShareRepository // TODO: MOVE TO THE ANDROID: COMMON @@ -80,7 +79,7 @@ import com.nextcloud.android.common.ui.share.repository.MockUnifiedShareReposito // TODO: EXPOSE ACTIONS, IMPLEMENT VIEWMODEL, REPOSITORY TO FETCH ACTUAL SHARE, INJECT NECESSARY PARAMETERS @Composable -fun UnifiedShareView(viewModel: UnifiedShareViewModel) { +fun UnifiedShareView(viewModel: ShareViewModel) { var showAddShare by remember { mutableStateOf(false) } val shares by viewModel.shares.collectAsState() @@ -598,7 +597,7 @@ fun UnifiedSharesListItem(share: UnifiedShares, type: UnifiedSharesListItemType) fun ComposeView.setupUnifiedShare(colorScheme: ColorScheme) { // TODO: REPLACE - val viewModel = UnifiedShareViewModel(repository = MockUnifiedShareRepository()) + val viewModel = ShareViewModel(repository = MockShareRepository()) setContent { MaterialTheme( diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt similarity index 77% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareViewModel.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 33310cb1..db00eea0 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/UnifiedShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -9,15 +9,15 @@ package com.nextcloud.android.common.ui.share import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.nextcloud.android.common.ui.share.model.UnifiedShares -import com.nextcloud.android.common.ui.share.repository.UnifiedShareRepository +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShares +import com.nextcloud.android.common.ui.share.repository.ShareRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class UnifiedShareViewModel(private val repository: UnifiedShareRepository): ViewModel() { +class ShareViewModel(private val repository: ShareRepository): ViewModel() { private val _shares = MutableStateFlow>(emptyList()) val shares: StateFlow> = _shares diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/CreateShareRequest.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/CreateShareRequest.kt new file mode 100644 index 00000000..54fa9da7 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/CreateShareRequest.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.create + +import com.nextcloud.android.common.ui.share.model.api.user.ShareUser +import kotlinx.serialization.Serializable + +@Serializable +data class CreateShareRequest( + val data: ShareDataRequest +) + +@Serializable +data class ShareDataRequest( + val sources: List, + val recipients: List, + val properties: Map>> +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt new file mode 100644 index 00000000..0a2fcb93 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.create + +import com.nextcloud.android.common.ui.share.model.api.owner.Owner +import com.nextcloud.android.common.ui.share.model.api.user.ShareUser +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ShareDataResponse( + val sources: List, + val recipients: List, + val properties: Map>>, + + val id: String, + + @SerialName("last_updated") + val lastUpdated: Long, + + val owner: Owner +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt new file mode 100644 index 00000000..cb75c5d7 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.owner + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class Owner( + @SerialName("user_id") + val userId: String, + + @SerialName("display_name") + val displayName: String +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/ShareRecipients.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/ShareRecipients.kt new file mode 100644 index 00000000..a9bba350 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/ShareRecipients.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.recipients + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ShareRecipients( + val type: String, + val value: String, + + @SerialName("display_name") + val displayName: String, + + @SerialName("display_name_unique") + val displayNameUnique: String, + + @SerialName("icon_url_light") + val iconUrlLight: String, + + @SerialName("icon_url_dark") + val iconUrlDark: String +) \ No newline at end of file diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/update/UpdateShareRequest.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/update/UpdateShareRequest.kt new file mode 100644 index 00000000..926177af --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/update/UpdateShareRequest.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.update + +import com.nextcloud.android.common.ui.share.model.api.owner.Owner +import com.nextcloud.android.common.ui.share.model.api.user.ShareUser +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateShareRequest( + val data: UpdateShareData +) + +@Serializable +data class UpdateShareData( + val sources: List, + val recipients: List, + val properties: Map>>, + + val id: String, + + @SerialName("last_updated") + val lastUpdated: Long, + + val owner: Owner +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/ShareUser.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/ShareUser.kt new file mode 100644 index 00000000..c0709a89 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/ShareUser.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.user + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ShareUser( + val type: String, + val value: String, + + @SerialName("display_name") + val displayName: String +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareCategory.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareCategory.kt similarity index 79% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareCategory.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareCategory.kt index bf9775cf..e93bd3d4 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareCategory.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareCategory.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.share.model +package com.nextcloud.android.common.ui.share.model.ui enum class UnifiedShareCategory { Invited, Anyone diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareDownloadLimit.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareDownloadLimit.kt similarity index 81% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareDownloadLimit.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareDownloadLimit.kt index d2d0ce5a..46a600be 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareDownloadLimit.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareDownloadLimit.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.share.model +package com.nextcloud.android.common.ui.share.model.ui data class UnifiedShareDownloadLimit( val limit: Int, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedSharePermission.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedSharePermission.kt similarity index 93% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedSharePermission.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedSharePermission.kt index 46150894..595230fb 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedSharePermission.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedSharePermission.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.share.model +package com.nextcloud.android.common.ui.share.model.ui sealed class UnifiedSharePermission { // file drop only for folder diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareType.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareType.kt similarity index 94% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareType.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareType.kt index f4c1139f..5e9c01d2 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShareType.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareType.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.share.model +package com.nextcloud.android.common.ui.share.model.ui import androidx.compose.material3.Icon import androidx.compose.runtime.Composable diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShares.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt similarity index 89% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShares.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt index 3b287701..4e7e28a4 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/UnifiedShares.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.share.model +package com.nextcloud.android.common.ui.share.model.ui data class UnifiedShares( val id: Int, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/UnifiedSharePreviews.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/SharePreviews.kt similarity index 96% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/previews/UnifiedSharePreviews.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/previews/SharePreviews.kt index 6671d6e3..eb23395f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/UnifiedSharePreviews.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/SharePreviews.kt @@ -20,7 +20,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.share.AnyoneShareContent import com.nextcloud.android.common.ui.share.CollapsibleSettingsSection @@ -32,15 +31,15 @@ import com.nextcloud.android.common.ui.share.ShareActionButtons import com.nextcloud.android.common.ui.share.ShareBottomSheetHeader import com.nextcloud.android.common.ui.share.ShareCategoryButtonGroup import com.nextcloud.android.common.ui.share.UnifiedShareView -import com.nextcloud.android.common.ui.share.UnifiedShareViewModel +import com.nextcloud.android.common.ui.share.ShareViewModel import com.nextcloud.android.common.ui.share.UnifiedSharesListItem import com.nextcloud.android.common.ui.share.UnifiedSharesListItemType -import com.nextcloud.android.common.ui.share.model.UnifiedShareCategory -import com.nextcloud.android.common.ui.share.model.UnifiedShareDownloadLimit -import com.nextcloud.android.common.ui.share.model.UnifiedSharePermission -import com.nextcloud.android.common.ui.share.model.UnifiedShareType -import com.nextcloud.android.common.ui.share.model.UnifiedShares -import com.nextcloud.android.common.ui.share.repository.MockUnifiedShareRepository +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareDownloadLimit +import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareType +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShares +import com.nextcloud.android.common.ui.share.repository.MockShareRepository @Composable private fun PreviewTheme( @@ -56,7 +55,7 @@ private fun PreviewTheme( @Composable fun Preview_UnifiedShareView_Light() { PreviewTheme { - UnifiedShareView(viewModel = UnifiedShareViewModel(MockUnifiedShareRepository())) + UnifiedShareView(viewModel = ShareViewModel(MockShareRepository())) } } @@ -64,7 +63,7 @@ fun Preview_UnifiedShareView_Light() { @Composable fun Preview_UnifiedShareView_Dark() { PreviewTheme(darkTheme = true) { - UnifiedShareView(viewModel = UnifiedShareViewModel(MockUnifiedShareRepository())) + UnifiedShareView(viewModel = ShareViewModel(MockShareRepository())) } } @@ -597,7 +596,7 @@ fun Preview_ItemShapes_AllTypes() { @Composable fun Preview_UnifiedShareView_Tablet() { PreviewTheme { - UnifiedShareView(viewModel = UnifiedShareViewModel(MockUnifiedShareRepository())) + UnifiedShareView(viewModel = ShareViewModel(MockShareRepository())) } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockUnifiedShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt similarity index 89% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockUnifiedShareRepository.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt index 85396398..5d3cb32b 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockUnifiedShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -7,13 +7,13 @@ package com.nextcloud.android.common.ui.share.repository -import com.nextcloud.android.common.ui.share.model.UnifiedShareCategory -import com.nextcloud.android.common.ui.share.model.UnifiedShareDownloadLimit -import com.nextcloud.android.common.ui.share.model.UnifiedSharePermission -import com.nextcloud.android.common.ui.share.model.UnifiedShareType -import com.nextcloud.android.common.ui.share.model.UnifiedShares +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareDownloadLimit +import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareType +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShares -class MockUnifiedShareRepository: UnifiedShareRepository { +class MockShareRepository: ShareRepository { override suspend fun fetchShares(): List { return listOf( UnifiedShares( diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt new file mode 100644 index 00000000..bbae3611 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -0,0 +1,91 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.repository + +import com.nextcloud.android.common.ui.network.ApiResult +import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest +import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse +import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients +import com.nextcloud.android.common.ui.share.model.api.update.UpdateShareRequest + +class ShareRemoteRepository: ShareRepository { + + // TODO: ALL OCS-APIRequest //boolean header + + /** + * Searches for recipients + */ + override suspend fun fetchRecipients( + recipientType: String, + query: String, + limit: Int, + offset: Int + ): ApiResult> { + /* + GET + /ocs/v2.php/apps/sharing/api/v1/recipients + + */ + + TODO("Not yet implemented") + } + + override suspend fun createShare(request: CreateShareRequest): ApiResult { + /* + POST + /ocs/v2.php/apps/sharing/api/v1/share + */ + TODO("Not yet implemented") + } + + override suspend fun fetchShare(id: String): ApiResult { + /* + POST + /ocs/v2.php/apps/sharing/api/v1/share/{id} + */ + TODO("Not yet implemented") + } + + override suspend fun updateShare(id: String, request: UpdateShareRequest): ApiResult { + /* + PUT + /ocs/v2.php/apps/sharing/api/v1/share/{id} + */ + TODO("Not yet implemented") + } + + override suspend fun deleteShare(id: String): ApiResult { + /* + DELETE + /ocs/v2.php/apps/sharing/api/v1/share/{id} + */ + TODO("Not yet implemented") + } + + /** + * @param sourceType + * Optional filter to return only shares matching a specific source type. + * When null, shares of all source types are returned. + * + * @param lastShareId + * Pagination cursor representing the last known share ID. + * Only shares with an ID greater than this value will be returned. + * When null, results start from the first available share. + */ + override suspend fun fetchShares( + sourceType: String?, + lastShareId: String?, + limit: Int + ): ApiResult> { + /* + GET + /ocs/v2.php/apps/sharing/api/v1/shares + */ + TODO("Not yet implemented") + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt new file mode 100644 index 00000000..99418a1b --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.repository + +import com.nextcloud.android.common.ui.network.ApiResult +import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest +import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse +import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients +import com.nextcloud.android.common.ui.share.model.api.update.UpdateShareRequest + +interface ShareRepository { + suspend fun fetchRecipients( + recipientType: String, + query: String, + limit: Int = 10, + offset: Int = 0 + ): ApiResult> + + suspend fun createShare(request: CreateShareRequest): ApiResult + + suspend fun fetchShare(id: String): ApiResult + + suspend fun updateShare(id: String, request: UpdateShareRequest): ApiResult + + suspend fun deleteShare(id: String): ApiResult + + suspend fun fetchShares( + sourceType: String? = null, + lastShareId: String? = null, + limit: Int = 100 + ): ApiResult> +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRemoteRepository.kt deleted file mode 100644 index e2d9beab..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRemoteRepository.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.repository - -import com.nextcloud.android.common.ui.share.model.UnifiedShares - -class UnifiedShareRemoteRepository: UnifiedShareRepository { - override suspend fun fetchShares(): List { - TODO("Not yet implemented") - } -} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRepository.kt deleted file mode 100644 index 09596cdb..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/UnifiedShareRepository.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.repository - -import com.nextcloud.android.common.ui.share.model.UnifiedShares - -interface UnifiedShareRepository { - suspend fun fetchShares(): List -} From 8fb13d61e606a43f832dee737a8233b2af01ef0d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 10:08:58 +0200 Subject: [PATCH 03/95] adopt mock models to api models Signed-off-by: alperozturk96 --- ui/src/main/AndroidManifest.xml | 2 +- .../android/common/ui/share/ShareView.kt | 4 +- .../android/common/ui/share/ShareViewModel.kt | 38 ++- .../model/api/create/ShareDataResponse.kt | 23 ++ .../ui/share/model/ui/UnifiedShareType.kt | 13 + .../common/ui/share/model/ui/UnifiedShares.kt | 24 +- .../common/ui/share/previews/SharePreviews.kt | 82 +++-- .../share/repository/MockShareRepository.kt | 294 ++++++++++++++---- .../share/repository/ShareRemoteRepository.kt | 3 +- .../ui/share/repository/ShareRepository.kt | 3 +- 10 files changed, 385 insertions(+), 101 deletions(-) diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml index b830d265..447589b1 100644 --- a/ui/src/main/AndroidManifest.xml +++ b/ui/src/main/AndroidManifest.xml @@ -5,6 +5,6 @@ ~ SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors ~ SPDX-License-Identifier: MIT --> - + diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 15e0c6a5..3b1d96b0 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -68,9 +68,9 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShares import com.nextcloud.android.common.ui.share.repository.MockShareRepository @@ -508,7 +508,7 @@ enum class UnifiedSharesListItemType { // NOTE: To just create a public link anyone tab + just send DOES SAME THING @Composable -fun UnifiedSharesListItem(share: UnifiedShares, type: UnifiedSharesListItemType) { +fun UnifiedSharesListItem(share: UnifiedShare, type: UnifiedSharesListItemType) { var showContextMenu by remember { mutableStateOf(false) } var showDetailSheet by remember { mutableStateOf(false) } val haptics = LocalHapticFeedback.current diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index db00eea0..9df7c264 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -9,7 +9,8 @@ package com.nextcloud.android.common.ui.share import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShares +import com.nextcloud.android.common.ui.network.ApiResult +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare import com.nextcloud.android.common.ui.share.repository.ShareRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -17,16 +18,39 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class ShareViewModel(private val repository: ShareRepository): ViewModel() { - private val _shares = MutableStateFlow>(emptyList()) - val shares: StateFlow> = _shares +class ShareViewModel( + private val repository: ShareRepository +) : ViewModel() { + + private val _shares = MutableStateFlow>(emptyList()) + val shares: StateFlow> = _shares + + private val _loading = MutableStateFlow(false) + val loading: StateFlow = _loading + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error init { + loadShares() + } + + private fun loadShares() { viewModelScope.launch(Dispatchers.IO) { - val shares = repository.fetchShares() - _shares.update { - shares + _loading.value = true + _error.value = null + + when (val result = repository.fetchShares()) { + is ApiResult.Success -> { + _shares.update { result.data } + } + + is ApiResult.Error -> { + _error.value = result.error.ocs.meta.message + } } + + _loading.value = false } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt index 0a2fcb93..39eb1e90 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt @@ -9,6 +9,10 @@ package com.nextcloud.android.common.ui.share.model.api.create import com.nextcloud.android.common.ui.share.model.api.owner.Owner import com.nextcloud.android.common.ui.share.model.api.user.ShareUser +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory +import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareType import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -25,3 +29,22 @@ data class ShareDataResponse( val owner: Owner ) + +fun ShareDataResponse.toUnifiedShare(): UnifiedShare { + val primarySource = sources.firstOrNull() + return UnifiedShare( + id = id, + sources = sources, + recipients = recipients, + properties = properties, + lastUpdated = lastUpdated, + owner = owner, + type = UnifiedShareType.toUnifiedShareType(primarySource?.type), + category = UnifiedShareCategory.Invited, // TODO map from properties + permission = UnifiedSharePermission.CanView, // TODO map from properties + label = primarySource?.displayName ?: "Unknown", + note = "", + password = "", + limit = null + ) +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareType.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareType.kt index 5e9c01d2..ec3a8acc 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareType.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareType.kt @@ -28,4 +28,17 @@ enum class UnifiedShareType { Icon(painterResource(iconId), contentDescription = "share type icon") } + + companion object { + fun toUnifiedShareType(value: String?): UnifiedShareType { + return when (value?.lowercase()) { + "user" -> InternalUser + "group" -> InternalGroup + "link" -> InternalLink + "federated" -> ExternalFederated + "mail" -> ExternalMail + else -> ExternalLink + } + } + } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt index 4e7e28a4..c77094bc 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt @@ -7,15 +7,23 @@ package com.nextcloud.android.common.ui.share.model.ui -data class UnifiedShares( - val id: Int, - val password: String, - val note: String, - val limit: UnifiedShareDownloadLimit, - val expirationDate: Int, +import com.nextcloud.android.common.ui.share.model.api.owner.Owner +import com.nextcloud.android.common.ui.share.model.api.user.ShareUser + +data class UnifiedShare( + val id: String, + val sources: List, + val recipients: List, + val properties: Map>>, + + val lastUpdated: Long, + val owner: Owner, + val permission: UnifiedSharePermission, - val label: String, - val sharedTo: String, val type: UnifiedShareType, val category: UnifiedShareCategory, + val label: String, + val note: String = "", + val password: String = "", + val limit: UnifiedShareDownloadLimit? = null ) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/SharePreviews.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/SharePreviews.kt index eb23395f..53be607c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/SharePreviews.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/SharePreviews.kt @@ -30,15 +30,17 @@ import com.nextcloud.android.common.ui.share.SettingsSwitchRow import com.nextcloud.android.common.ui.share.ShareActionButtons import com.nextcloud.android.common.ui.share.ShareBottomSheetHeader import com.nextcloud.android.common.ui.share.ShareCategoryButtonGroup -import com.nextcloud.android.common.ui.share.UnifiedShareView import com.nextcloud.android.common.ui.share.ShareViewModel +import com.nextcloud.android.common.ui.share.UnifiedShareView import com.nextcloud.android.common.ui.share.UnifiedSharesListItem import com.nextcloud.android.common.ui.share.UnifiedSharesListItemType +import com.nextcloud.android.common.ui.share.model.api.owner.Owner +import com.nextcloud.android.common.ui.share.model.api.user.ShareUser +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareDownloadLimit import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareType -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShares import com.nextcloud.android.common.ui.share.repository.MockShareRepository @Composable @@ -119,7 +121,7 @@ fun Preview_UnifiedSharesListItem_AllTypes() { } @Composable -private fun UnifiedSharesListItemPreviewHelper(share: UnifiedShares, type: UnifiedSharesListItemType) { +private fun UnifiedSharesListItemPreviewHelper(share: UnifiedShare, type: UnifiedSharesListItemType) { UnifiedSharesListItem(share = share, type = type) } @@ -708,45 +710,81 @@ private fun AnyoneInlineSettingsPreview() { private fun previewUserShare( permission: UnifiedSharePermission = UnifiedSharePermission.CanView -) = UnifiedShares( +) = UnifiedShare( + id = "1", + sources = listOf( + ShareUser( + type = "user", + value = "alice@company.com", + displayName = "Alice Smith" + ) + ), + recipients = emptyList(), + properties = emptyMap(), + lastUpdated = 0, + owner = Owner( + userId = "alice", + displayName = "Alice Smith" + ), label = "Alice Smith", type = UnifiedShareType.InternalUser, permission = permission, - expirationDate = 0, - sharedTo = "", category = UnifiedShareCategory.Invited, - id = 1, - password = "", note = "", - limit = UnifiedShareDownloadLimit(0, 0), + password = "", + limit = UnifiedShareDownloadLimit(0, 0) ) private fun previewGroupShare( permission: UnifiedSharePermission = UnifiedSharePermission.CanEdit -) = UnifiedShares( +) = UnifiedShare( + id = "2", + sources = listOf( + ShareUser( + type = "group", + value = "design", + displayName = "Design Team" + ) + ), + recipients = emptyList(), + properties = emptyMap(), + lastUpdated = 0, + owner = Owner( + userId = "system", + displayName = "System" + ), label = "Design Team", type = UnifiedShareType.InternalGroup, permission = permission, - expirationDate = 0, - sharedTo = "", category = UnifiedShareCategory.Invited, - id = 1, - password = "", note = "", - limit = UnifiedShareDownloadLimit(0, 0), + password = "", + limit = UnifiedShareDownloadLimit(0, 0) ) private fun previewPublicLinkShare( permission: UnifiedSharePermission = UnifiedSharePermission.FileDrop -) = UnifiedShares( +) = UnifiedShare( + id = "3", + sources = listOf( + ShareUser( + type = "link", + value = "https://nextcloud.com/s/abc123", + displayName = "Public Link" + ) + ), + recipients = emptyList(), + properties = emptyMap(), + lastUpdated = 1710000000, + owner = Owner( + userId = "system", + displayName = "System" + ), label = "Public link", type = UnifiedShareType.ExternalLink, permission = permission, - expirationDate = 0, - sharedTo = "", - category = UnifiedShareCategory.Invited, - id = 1, - password = "", + category = UnifiedShareCategory.Anyone, note = "", - limit = UnifiedShareDownloadLimit(0, 0), + password = "1234", + limit = UnifiedShareDownloadLimit(50, 5) ) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt index 5d3cb32b..73aa1227 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -7,56 +7,199 @@ package com.nextcloud.android.common.ui.share.repository -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareDownloadLimit -import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareType -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShares - -class MockShareRepository: ShareRepository { - override suspend fun fetchShares(): List { - return listOf( - UnifiedShares( - id = 1, - password = "", - note = "Design review – please check latest changes", - limit = UnifiedShareDownloadLimit( - limit = 100, - downloadCount = 12 +import com.nextcloud.android.common.ui.network.ApiResult +import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest +import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse +import com.nextcloud.android.common.ui.share.model.api.owner.Owner +import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients +import com.nextcloud.android.common.ui.share.model.api.update.UpdateShareRequest +import com.nextcloud.android.common.ui.share.model.api.user.ShareUser +import com.nextcloud.android.common.ui.share.model.ui.* + +class MockShareRepository : ShareRepository { + override suspend fun fetchRecipients( + recipientType: String, + query: String, + limit: Int, + offset: Int + ): ApiResult> { + + val mock = listOf( + ShareRecipients( + type = recipientType, + value = "alice@company.com", + displayName = "Alice Johnson", + displayNameUnique = "Alice Johnson (Company)", + iconUrlLight = "https://mock/icons/user_light.png", + iconUrlDark = "https://mock/icons/user_dark.png" + ), + + ShareRecipients( + type = recipientType, + value = "marketing", + displayName = "Marketing Team", + displayNameUnique = "Marketing Team (Group)", + iconUrlLight = "https://mock/icons/group_light.png", + iconUrlDark = "https://mock/icons/group_dark.png" + ), + + ShareRecipients( + type = recipientType, + value = "john@external.com", + displayName = "John External", + displayNameUnique = "John External (External)", + iconUrlLight = "https://mock/icons/external_light.png", + iconUrlDark = "https://mock/icons/external_dark.png" + ) + ) + + return ApiResult.Success(mock) + } + + override suspend fun createShare( + request: CreateShareRequest + ): ApiResult { + + val response = ShareDataResponse( + sources = request.data.sources, + recipients = request.data.recipients, + properties = request.data.properties, + id = "mock-share-${System.currentTimeMillis()}", + lastUpdated = System.currentTimeMillis(), + owner = Owner( + userId = "mock-user", + displayName = "Mock User" + ) + ) + + return ApiResult.Success(response) + } + + override suspend fun fetchShare(id: String): ApiResult { + + val mock = ShareDataResponse( + sources = emptyList(), + recipients = listOf( + ShareUser( + type = "user", + value = "alice@company.com", + displayName = "Alice Johnson" + ) + ), + properties = emptyMap(), + id = id, + lastUpdated = 0, + owner = Owner( + userId = "alice", + displayName = "Alice Johnson" + ) + ) + + return ApiResult.Success(mock) + } + + override suspend fun updateShare( + id: String, + request: UpdateShareRequest + ): ApiResult { + + val updated = ShareDataResponse( + sources = request.data.sources, + recipients = request.data.recipients, + properties = request.data.properties, + id = id, + lastUpdated = System.currentTimeMillis(), + owner = request.data.owner + ) + + return ApiResult.Success(updated) + } + + override suspend fun deleteShare(id: String): ApiResult { + return ApiResult.Success(Unit) + } + + override suspend fun fetchShares( + sourceType: String?, + lastShareId: String?, + limit: Int + ): ApiResult> { + val data = listOf( + UnifiedShare( + id = "1", + sources = emptyList(), + recipients = listOf( + ShareUser( + type = "user", + value = "alice@company.com", + displayName = "Alice Johnson" + ) + ), + properties = emptyMap(), + lastUpdated = 0, + owner = Owner( + userId = "alice", + displayName = "Alice Johnson" ), - expirationDate = 0, + permission = UnifiedSharePermission.CanView, label = "Alice Johnson", - sharedTo = "alice@company.com", + note = "Design review – please check latest changes", + password = "", type = UnifiedShareType.InternalUser, - category = UnifiedShareCategory.Invited + category = UnifiedShareCategory.Invited, + limit = UnifiedShareDownloadLimit( + limit = 100, + downloadCount = 12 + ) ), - UnifiedShares( - id = 2, - password = "", - note = "", - limit = UnifiedShareDownloadLimit( - limit = 0, - downloadCount = 0 + UnifiedShare( + id = "2", + sources = emptyList(), + recipients = listOf( + ShareUser( + type = "group", + value = "marketing", + displayName = "Marketing Team" + ) + ), + properties = emptyMap(), + lastUpdated = 0, + owner = Owner( + userId = "system", + displayName = "System" ), - expirationDate = 0, + permission = UnifiedSharePermission.CanEdit, label = "Marketing Team", - sharedTo = "marketing", + note = "", + password = "", type = UnifiedShareType.InternalGroup, - category = UnifiedShareCategory.Invited + category = UnifiedShareCategory.Invited, + limit = UnifiedShareDownloadLimit( + limit = 0, + downloadCount = 0 + ) ), - UnifiedShares( - id = 3, - password = "1234", - note = "Public link for client review", - limit = UnifiedShareDownloadLimit( - limit = 50, - downloadCount = 5 + UnifiedShare( + id = "3", + sources = listOf( + ShareUser( + type = "link", + value = "https://nextcloud.com/s/abc123", + displayName = "Public Link" + ) + ), + recipients = emptyList(), + properties = emptyMap(), + lastUpdated = 1710000000, + owner = Owner( + userId = "system", + displayName = "System" ), - expirationDate = 1710000000, + permission = UnifiedSharePermission.Custom( read = true, edit = false, @@ -64,42 +207,75 @@ class MockShareRepository: ShareRepository { create = false ), label = "Public Link", - sharedTo = "https://nextcloud.com/s/abc123", + note = "Public link for client review", + password = "1234", type = UnifiedShareType.InternalLink, - category = UnifiedShareCategory.Anyone + category = UnifiedShareCategory.Anyone, + limit = UnifiedShareDownloadLimit( + limit = 50, + downloadCount = 5 + ) ), - UnifiedShares( - id = 4, - password = "", - note = "External partner access", - limit = UnifiedShareDownloadLimit( - limit = 20, - downloadCount = 2 + UnifiedShare( + id = "4", + sources = emptyList(), + recipients = listOf( + ShareUser( + type = "mail", + value = "john@external.com", + displayName = "John External" + ) ), - expirationDate = 0, + properties = emptyMap(), + lastUpdated = 0, + owner = Owner( + userId = "john", + displayName = "John External" + ), + permission = UnifiedSharePermission.CanView, label = "John External", - sharedTo = "john@external.com", + note = "External partner access", + password = "", type = UnifiedShareType.ExternalMail, - category = UnifiedShareCategory.Anyone + category = UnifiedShareCategory.Anyone, + limit = UnifiedShareDownloadLimit( + limit = 20, + downloadCount = 2 + ) ), - UnifiedShares( - id = 5, - password = "", - note = "Federated sharing with partner instance", - limit = UnifiedShareDownloadLimit( - limit = 0, - downloadCount = 0 + UnifiedShare( + id = "5", + sources = emptyList(), + recipients = listOf( + ShareUser( + type = "federated", + value = "partner@nextcloud.org", + displayName = "Partner Cloud" + ) ), - expirationDate = 0, + properties = emptyMap(), + lastUpdated = 0, + owner = Owner( + userId = "partner", + displayName = "Partner Cloud" + ), + permission = UnifiedSharePermission.FileDrop, label = "Partner Cloud", - sharedTo = "partner@nextcloud.org", + note = "Federated sharing with partner instance", + password = "", type = UnifiedShareType.ExternalFederated, - category = UnifiedShareCategory.Anyone + category = UnifiedShareCategory.Anyone, + limit = UnifiedShareDownloadLimit( + limit = 0, + downloadCount = 0 + ) ) ) + + return ApiResult.Success(data) } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index bbae3611..bea010e3 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -12,6 +12,7 @@ import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients import com.nextcloud.android.common.ui.share.model.api.update.UpdateShareRequest +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare class ShareRemoteRepository: ShareRepository { @@ -81,7 +82,7 @@ class ShareRemoteRepository: ShareRepository { sourceType: String?, lastShareId: String?, limit: Int - ): ApiResult> { + ): ApiResult> { /* GET /ocs/v2.php/apps/sharing/api/v1/shares diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt index 99418a1b..c6bb626d 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt @@ -12,6 +12,7 @@ import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients import com.nextcloud.android.common.ui.share.model.api.update.UpdateShareRequest +import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare interface ShareRepository { suspend fun fetchRecipients( @@ -33,5 +34,5 @@ interface ShareRepository { sourceType: String? = null, lastShareId: String? = null, limit: Int = 100 - ): ApiResult> + ): ApiResult> } From e81fbd007a5a5207ca7992da30bfe0082b2e9d2d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 12:41:47 +0200 Subject: [PATCH 04/95] add translations, bind ui actions Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 408 +++++---- .../android/common/ui/share/ShareViewModel.kt | 39 +- .../model/api/create/ShareDataResponse.kt | 6 +- .../share/model/ui/ShareBottomSheetState.kt | 14 + .../share/model/ui/UnifiedSharePermission.kt | 63 +- .../common/ui/share/model/ui/UnifiedShares.kt | 30 +- .../common/ui/share/previews/SharePreviews.kt | 790 ------------------ ui/src/main/res/values/strings.xml | 60 ++ 8 files changed, 449 insertions(+), 961 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/previews/SharePreviews.kt create mode 100644 ui/src/main/res/values/strings.xml diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 3b1d96b0..caed3858 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -7,6 +7,8 @@ package com.nextcloud.android.common.ui.share +import android.content.ClipData +import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -21,6 +23,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -46,17 +50,23 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -64,14 +74,22 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.toClipEntry import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.ui.ShareBottomSheetState import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission +import com.nextcloud.android.common.ui.share.model.ui.customPermissionFields import com.nextcloud.android.common.ui.share.repository.MockShareRepository +import kotlinx.coroutines.launch // TODO: MOVE TO THE ANDROID: COMMON @@ -79,77 +97,105 @@ import com.nextcloud.android.common.ui.share.repository.MockShareRepository // TODO: EXPOSE ACTIONS, IMPLEMENT VIEWMODEL, REPOSITORY TO FETCH ACTUAL SHARE, INJECT NECESSARY PARAMETERS @Composable -fun UnifiedShareView(viewModel: ShareViewModel) { - var showAddShare by remember { mutableStateOf(false) } +private fun ShareView(viewModel: ShareViewModel) { + val errorMessageId by viewModel.errorMessageId.collectAsState() + var bottomSheetState by remember { mutableStateOf(ShareBottomSheetState.Idle) } val shares by viewModel.shares.collectAsState() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - shares.forEachIndexed { index, share -> - val type = when (index) { - 0 -> { - UnifiedSharesListItemType.Top - } + LaunchedEffect(errorMessageId) { + errorMessageId?.let { + snackbarHostState.showSnackbar(context.getString(it)) + viewModel.updateErrorMessage(null) + } + } - shares.lastIndex -> { - UnifiedSharesListItemType.Bottom - } + Scaffold(floatingActionButton = { + FloatingActionButton( + onClick = { bottomSheetState = ShareBottomSheetState.New(UnifiedShare.new()) }, + ) { + Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") + } + }, snackbarHost = { + SnackbarHost(snackbarHostState) + }) { + LazyColumn(modifier = Modifier.padding(it)) { + itemsIndexed(shares) { index, share -> + val type = when (index) { + 0 -> { + UnifiedSharesListItemType.Top + } + + shares.lastIndex -> { + UnifiedSharesListItemType.Bottom + } - else -> { - UnifiedSharesListItemType.Mid + else -> { + UnifiedSharesListItemType.Mid + } } - } - UnifiedSharesListItem(share, type) + UnifiedSharesListItem(share, type, onSelectShare = { share -> + bottomSheetState = ShareBottomSheetState.Edit(share) + }, onDeleteShare = { + viewModel.delete(share) + }, onSendEmail = { + // TODO: + }) + } } + } - FloatingActionButton( - onClick = { showAddShare = true }, - modifier = Modifier - .align(Alignment.End) - .padding(top = 16.dp) - ) { - Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") + when (bottomSheetState) { + is ShareBottomSheetState.Edit -> { + val state = (bottomSheetState as ShareBottomSheetState.Edit) + AddOrEditShareBottomSheet( + title = stringResource(R.string.share_view_bottom_sheet_edit_title, state.share.label), + share = state.share, + onDismiss = { bottomSheetState = ShareBottomSheetState.Idle } + ) } - if (showAddShare) { - AddShareBottomSheet("Abc.txt",onDismiss = { showAddShare = false }) + is ShareBottomSheetState.New -> { + val state = (bottomSheetState as ShareBottomSheetState.New) + AddOrEditShareBottomSheet( + title = stringResource(R.string.share_view_bottom_sheet_new_title), + share = state.newShare, + onDismiss = { bottomSheetState = ShareBottomSheetState.Idle } + ) } + + ShareBottomSheetState.Idle -> Unit } } // TODO: Use like inner tags whenever user add a new people to the search and it should look like User 1, Group 1 etc. - @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AddShareBottomSheet(filename: String, onDismiss: () -> Unit) { +private fun AddOrEditShareBottomSheet(title: String, share: UnifiedShare, onDismiss: () -> Unit) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scrollState = rememberScrollState() - var category by remember { mutableStateOf(UnifiedShareCategory.Invited) } - var permission by remember { mutableStateOf(UnifiedSharePermission.CanView) } + var category by remember { mutableStateOf(share.category) } + var permission by remember { mutableStateOf(share.permission ?: UnifiedSharePermission.CanView) } var searchQuery by remember { mutableStateOf("") } - var note by remember { mutableStateOf("") } + var note by remember { mutableStateOf(share.note) } // Toggle states for collapse/expand var showInvitedSettings by remember { mutableStateOf(false) } var showAnyoneSettings by remember { mutableStateOf(false) } - var viewFiles by remember { mutableStateOf(false) } - var editFiles by remember { mutableStateOf(false) } - var createFiles by remember { mutableStateOf(false) } - var deleteFiles by remember { mutableStateOf(false) } + val clipboard = LocalClipboard.current + val context = LocalContext.current + val scope = rememberCoroutineScope() val availablePermissions = remember { listOf( UnifiedSharePermission.CanView, UnifiedSharePermission.CanEdit, UnifiedSharePermission.FileDrop, - UnifiedSharePermission.Custom(false, false, false, false) + UnifiedSharePermission.Custom.getFromPermission(share.permission) ) } @@ -166,7 +212,12 @@ fun AddShareBottomSheet(filename: String, onDismiss: () -> Unit) { .verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - ShareBottomSheetHeader(filename) + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) ShareCategoryButtonGroup( selectedCategory = category, @@ -186,7 +237,7 @@ fun AddShareBottomSheet(filename: String, onDismiss: () -> Unit) { isExpanded = showInvitedSettings, onToggle = { showInvitedSettings = !showInvitedSettings } ) { - InvitedInlineSettings() + InvitedInlineSettings(share) } } else { AnyoneShareContent( @@ -196,27 +247,39 @@ fun AddShareBottomSheet(filename: String, onDismiss: () -> Unit) { ) if (permission is UnifiedSharePermission.Custom) { - SettingsSwitchRow("View files", viewFiles) { viewFiles = it } - SettingsSwitchRow("Edit files", editFiles) { editFiles = it } - SettingsSwitchRow("Create files", createFiles) { createFiles = it } - SettingsSwitchRow("Delete files", deleteFiles) { deleteFiles = it } + val customPermissions = permission as UnifiedSharePermission.Custom + + customPermissionFields.forEach { field -> + SettingsSwitchRow( + label = stringResource(field.labelRes), + checked = field.getValue(customPermissions), + onCheckedChange = { permission = field.setValue(customPermissions, it) } + ) + } } CollapsibleSettingsSection( isExpanded = showAnyoneSettings, onToggle = { showAnyoneSettings = !showAnyoneSettings } ) { - AnyoneInlineSettings() + AnyoneInlineSettings(share) } } NoteToRecipients(note = note, onNoteChange = { note = it }) - ShareActionButtons( - category = category, + share = share, isSendEnabled = searchQuery.isNotBlank(), - onCopyClick = { /* TODO */ }, + onCopyClick = { + val label = context.getString(R.string.share_view_copy_to_clipboard_label) + + scope.launch { + val clipData = + ClipData.newPlainText(label, it) + clipboard.setClipEntry(clipData.toClipEntry()) + } + }, onSendClick = { /* TODO */ } ) } @@ -224,7 +287,7 @@ fun AddShareBottomSheet(filename: String, onDismiss: () -> Unit) { } @Composable -fun CollapsibleSettingsSection( +private fun CollapsibleSettingsSection( isExpanded: Boolean, onToggle: () -> Unit, content: @Composable () -> Unit @@ -239,7 +302,7 @@ fun CollapsibleSettingsSection( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = "Settings", + text = stringResource(R.string.share_view_advanced_settings), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) @@ -258,19 +321,9 @@ fun CollapsibleSettingsSection( } } -@Composable -fun ShareBottomSheetHeader(filename: String) { - Text( - text = "Share $filename", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp) - ) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ShareCategoryButtonGroup( +private fun ShareCategoryButtonGroup( selectedCategory: UnifiedShareCategory, onCategoryChange: (UnifiedShareCategory) -> Unit ) { @@ -293,7 +346,7 @@ fun ShareCategoryButtonGroup( } @Composable -fun InvitedShareContent( +private fun InvitedShareContent( searchQuery: String, onSearchChange: (String) -> Unit, permission: UnifiedSharePermission, @@ -306,14 +359,14 @@ fun InvitedShareContent( value = searchQuery, onValueChange = onSearchChange, modifier = Modifier.fillMaxWidth(), - label = { Text("Add people") }, - placeholder = { Text("Name, team, email or federated ID") }, + label = { Text(stringResource(R.string.share_view_invited_category_label)) }, + placeholder = { Text(stringResource(R.string.share_view_invited_category_placeholder)) }, singleLine = true, shape = RoundedCornerShape(8.dp) ) PermissionDropdown( - label = "Participants", + label = stringResource(R.string.share_view_invited_category_participants), selectedPermission = permission, availablePermissions = availablePermissions, onPermissionChange = onPermissionChange @@ -322,7 +375,7 @@ fun InvitedShareContent( } @Composable -fun NoteToRecipients( +private fun NoteToRecipients( note: String, onNoteChange: (String) -> Unit ) { @@ -330,20 +383,20 @@ fun NoteToRecipients( value = note, onValueChange = onNoteChange, modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Note to recipients") }, + placeholder = { Text(stringResource(R.string.share_view_note_text_field_placeholder)) }, shape = RoundedCornerShape(8.dp) ) } @Composable -fun AnyoneShareContent( +private fun AnyoneShareContent( permission: UnifiedSharePermission, availablePermissions: List, onPermissionChange: (UnifiedSharePermission) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { PermissionDropdown( - label = "Anyone with the link", + label = stringResource(R.string.share_view_permission_dropdown_label), selectedPermission = permission, availablePermissions = availablePermissions, onPermissionChange = onPermissionChange @@ -353,7 +406,7 @@ fun AnyoneShareContent( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PermissionDropdown( +private fun PermissionDropdown( label: String, selectedPermission: UnifiedSharePermission, availablePermissions: List, @@ -367,7 +420,7 @@ fun PermissionDropdown( modifier = Modifier.fillMaxWidth() ) { OutlinedTextField( - value = selectedPermission.getText(), + value = stringResource(selectedPermission.getTextId()), onValueChange = {}, readOnly = true, label = { Text(label) }, @@ -383,7 +436,7 @@ fun PermissionDropdown( ) { availablePermissions.forEach { option -> DropdownMenuItem( - text = { Text(option.getText()) }, + text = { Text(stringResource(option.getTextId())) }, onClick = { onPermissionChange(option) expanded = false @@ -395,55 +448,72 @@ fun PermissionDropdown( } @Composable -private fun InvitedInlineSettings() { - var shareWithOthers by remember { mutableStateOf(false) } - var editFile by remember { mutableStateOf(false) } - var hasExpiration by remember { mutableStateOf(false) } - var hideDownload by remember { mutableStateOf(false) } - - Column { - SettingsSwitchRow("Share with others", shareWithOthers) { shareWithOthers = it } - SettingsSwitchRow("Edit file", editFile) { editFile = it } - SettingsSwitchRow("Expiration date", hasExpiration) { hasExpiration = it } - SettingsSwitchRow("Hide download and sync options", hideDownload) { hideDownload = it } - } +private fun InvitedInlineSettings(share: UnifiedShare) { + var shareWithOthers by remember { mutableStateOf(share.recipients.isNotEmpty()) } + var editFile by remember { mutableStateOf((share.permission as? UnifiedSharePermission.CanEdit) != null) } + var hasExpiration by remember { mutableStateOf(false) } // TODO + var hideDownload by remember { mutableStateOf(false) } // TODO + + SettingsSwitchRow(stringResource(R.string.share_view_invited_category_share_with_others_switch), shareWithOthers) { shareWithOthers = it } + SettingsSwitchRow(stringResource(R.string.share_view_invited_category_edit_file_switch), editFile) { editFile = it } + SettingsSwitchRow(stringResource(R.string.share_view_invited_category_expiration_date_switch), hasExpiration) { hasExpiration = it } + SettingsSwitchRow(stringResource(R.string.share_view_invited_category_hide_and_download_switch), hideDownload) { hideDownload = it } } @Composable -private fun AnyoneInlineSettings() { - var hasPassword by remember { mutableStateOf(false) } +private fun AnyoneInlineSettings(share: UnifiedShare) { + var hasPassword by remember { mutableStateOf(share.password.isNotEmpty()) } var hasExpiration by remember { mutableStateOf(false) } - var limitDownloads by remember { mutableStateOf(false) } + var limitDownloads by remember { mutableStateOf(share.limit != null) } var hideDownloads by remember { mutableStateOf(false) } var videoVerification by remember { mutableStateOf(false) } var showFilesInGridView by remember { mutableStateOf(false) } - Column { - OutlinedTextField( - value = "", - onValueChange = {}, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - label = { Text("Label") }, - placeholder = { Text("Optional name for this link") }, - singleLine = true - ) - - SettingsSwitchRow("Expiration date", hasExpiration) { hasExpiration = it } - SettingsSwitchRow("Password", hasPassword) { hasPassword = it } - SettingsSwitchRow("Limit downloads", limitDownloads) { limitDownloads = it } - - SettingsSwitchRow("Hide downloads", hideDownloads) { hideDownloads = it } - SettingsSwitchRow("Video verification", videoVerification) { videoVerification = it } - SettingsSwitchRow("Show files in grid view", showFilesInGridView) { showFilesInGridView = it } + OutlinedTextField( + value = "", + onValueChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + label = { Text(stringResource(R.string.share_view_anyone_category_label)) }, + placeholder = { Text(stringResource(R.string.share_view_anyone_category_label_placeholder)) }, + singleLine = true + ) - } + SettingsSwitchRow( + stringResource(R.string.share_view_anyone_category_expiration_date_switch), + hasExpiration + ) { hasExpiration = it } + + SettingsSwitchRow( + stringResource(R.string.share_view_anyone_category_password_switch), + hasPassword + ) { hasPassword = it } + + SettingsSwitchRow( + stringResource(R.string.share_view_anyone_category_limit_downloads_switch), + limitDownloads + ) { limitDownloads = it } + + SettingsSwitchRow( + stringResource(R.string.share_view_anyone_category_hide_downloads_switch), + hideDownloads + ) { hideDownloads = it } + + SettingsSwitchRow( + stringResource(R.string.share_view_anyone_category_video_verification_switch), + videoVerification + ) { videoVerification = it } + + SettingsSwitchRow( + stringResource(R.string.share_view_anyone_category_grid_view_switch), + showFilesInGridView + ) { showFilesInGridView = it } } @Composable -fun SettingsSwitchRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { +private fun SettingsSwitchRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -457,37 +527,38 @@ fun SettingsSwitchRow(label: String, checked: Boolean, onCheckedChange: (Boolean } @Composable -fun ShareActionButtons( - category: UnifiedShareCategory, +private fun ShareActionButtons( + share: UnifiedShare, isSendEnabled: Boolean, - onCopyClick: () -> Unit, + onCopyClick: (String) -> Unit, onSendClick: () -> Unit ) { - Row(modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp)) { - if (category == UnifiedShareCategory.Invited) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) { + if (share.category == UnifiedShareCategory.Invited) { FilledTonalButton( - onClick = onCopyClick, + onClick = { onCopyClick("TODO") }, modifier = Modifier.weight(1f) ) { - Text("Copy link") + Text(stringResource(R.string.share_view_copy_action)) } Spacer(modifier = Modifier.width(16.dp)) Button( onClick = onSendClick, modifier = Modifier.weight(1f), - enabled = isSendEnabled // Disabled if search query is empty + enabled = isSendEnabled ) { - Text("Send") + Text(stringResource(R.string.share_view_send_action)) } } else { - // For "Anyone" (Public link), usually just one big action to create/copy Button( - onClick = onCopyClick, + onClick = { onCopyClick("TODO") }, modifier = Modifier.fillMaxWidth() ) { - Text("Create public link") + Text(stringResource(R.string.share_view_create_public_link)) } } } @@ -508,9 +579,14 @@ enum class UnifiedSharesListItemType { // NOTE: To just create a public link anyone tab + just send DOES SAME THING @Composable -fun UnifiedSharesListItem(share: UnifiedShare, type: UnifiedSharesListItemType) { +private fun UnifiedSharesListItem( + share: UnifiedShare, + type: UnifiedSharesListItemType, + onSelectShare: (UnifiedShare) -> Unit, + onDeleteShare: (UnifiedShare) -> Unit, + onSendEmail: (UnifiedShare) -> Unit +) { var showContextMenu by remember { mutableStateOf(false) } - var showDetailSheet by remember { mutableStateOf(false) } val haptics = LocalHapticFeedback.current ListItem( @@ -518,7 +594,7 @@ fun UnifiedSharesListItem(share: UnifiedShare, type: UnifiedSharesListItemType) .fillMaxWidth() .clip(type.getShape()) .combinedClickable( - onClick = { showDetailSheet = true }, + onClick = { onSelectShare(share) }, onLongClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) showContextMenu = true @@ -526,14 +602,16 @@ fun UnifiedSharesListItem(share: UnifiedShare, type: UnifiedSharesListItemType) ) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), leadingContent = { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center - ) { - share.type.Icon() + share.type?.let { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + it.Icon() + } } }, headlineContent = { @@ -543,11 +621,13 @@ fun UnifiedSharesListItem(share: UnifiedShare, type: UnifiedSharesListItemType) ) }, supportingContent = { - Text( - text = share.permission.getText(), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + share.permission?.getTextId()?.let { + Text( + text = stringResource(it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } }, trailingContent = { Box { @@ -560,23 +640,29 @@ fun UnifiedSharesListItem(share: UnifiedShare, type: UnifiedSharesListItemType) onDismissRequest = { showContextMenu = false } ) { DropdownMenuItem( - text = { Text("Edit") }, + text = { Text(stringResource(R.string.share_view_list_item_edit)) }, onClick = { showContextMenu = false - showDetailSheet = true + onSelectShare(share) } ) DropdownMenuItem( - text = { Text("Send email") }, - onClick = { showContextMenu = false } + text = { Text(stringResource(R.string.share_view_list_item_send_email)) }, + onClick = { + onSendEmail(share) + showContextMenu = false + } ) HorizontalDivider() DropdownMenuItem( - text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, - onClick = { showContextMenu = false } + text = { Text(stringResource(R.string.share_view_list_item_delete), color = MaterialTheme.colorScheme.error) }, + onClick = { + onDeleteShare(share) + showContextMenu = false + } ) } } @@ -585,13 +671,31 @@ fun UnifiedSharesListItem(share: UnifiedShare, type: UnifiedSharesListItemType) containerColor = Color.Transparent ) ) +} - // TODO: USE EXISTING SHARE DETAILS - if (showDetailSheet) { - AddShareBottomSheet( - filename = share.label, - onDismiss = { showDetailSheet = false } - ) +@Preview(name = "UnifiedShareView – light", showBackground = true) +@Composable +private fun PreviewLight() { + PreviewTheme { + ShareView(viewModel = ShareViewModel(MockShareRepository())) + } +} + +@Preview(name = "UnifiedShareView – dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewDark() { + PreviewTheme(darkTheme = true) { + ShareView(viewModel = ShareViewModel(MockShareRepository())) + } +} + +@Composable +private fun PreviewTheme( + darkTheme: Boolean = false, + content: @Composable () -> Unit +) { + MaterialTheme { + Surface(content = content) } } @@ -603,7 +707,7 @@ fun ComposeView.setupUnifiedShare(colorScheme: ColorScheme) { MaterialTheme( colorScheme = colorScheme, content = { - UnifiedShareView(viewModel) + ShareView(viewModel) } ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 9df7c264..39f948e3 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -9,6 +9,7 @@ package com.nextcloud.android.common.ui.share import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.network.ApiResult import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare import com.nextcloud.android.common.ui.share.repository.ShareRepository @@ -28,17 +29,18 @@ class ShareViewModel( private val _loading = MutableStateFlow(false) val loading: StateFlow = _loading - private val _error = MutableStateFlow(null) - val error: StateFlow = _error + private val _errorMessageId = MutableStateFlow(null) + val errorMessageId: StateFlow = _errorMessageId init { loadShares() } + // region private methods private fun loadShares() { viewModelScope.launch(Dispatchers.IO) { _loading.value = true - _error.value = null + _errorMessageId.value = null when (val result = repository.fetchShares()) { is ApiResult.Success -> { @@ -46,11 +48,40 @@ class ShareViewModel( } is ApiResult.Error -> { - _error.value = result.error.ocs.meta.message + _errorMessageId.value = R.string.share_view_fetch_error_message } } _loading.value = false } } + // endregion + + // region public methods + fun delete(share: UnifiedShare) { + viewModelScope.launch(Dispatchers.IO) { + val id = share.id + if (id == null) { + _errorMessageId.update { + R.string.share_view_delete_error_id_not_found_message + } + return@launch + } + + val result = repository.deleteShare(share.id) + if (result is ApiResult.Error) { + _errorMessageId.update { + R.string.share_view_delete_error_message + } + return@launch + } + } + } + + fun updateErrorMessage(value: Int?) { + _errorMessageId.update { + value + } + } + // endregion } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt index 39eb1e90..1d2ec2f2 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt @@ -43,8 +43,8 @@ fun ShareDataResponse.toUnifiedShare(): UnifiedShare { category = UnifiedShareCategory.Invited, // TODO map from properties permission = UnifiedSharePermission.CanView, // TODO map from properties label = primarySource?.displayName ?: "Unknown", - note = "", - password = "", - limit = null + note = "", // TODO map from properties + password = "", // TODO map from properties + limit = null // TODO map from properties ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt new file mode 100644 index 00000000..a5bfd600 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.ui + +sealed class ShareBottomSheetState { + data object Idle: ShareBottomSheetState() + data class New(val newShare: UnifiedShare): ShareBottomSheetState() + data class Edit(val share: UnifiedShare): ShareBottomSheetState() +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedSharePermission.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedSharePermission.kt index 595230fb..b6511314 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedSharePermission.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedSharePermission.kt @@ -7,6 +7,8 @@ package com.nextcloud.android.common.ui.share.model.ui +import com.nextcloud.android.common.ui.R + sealed class UnifiedSharePermission { // file drop only for folder data object FileDrop : UnifiedSharePermission() @@ -15,16 +17,63 @@ sealed class UnifiedSharePermission { data object CanEdit : UnifiedSharePermission() // create only for folder - data class Custom(val read: Boolean, val edit: Boolean, val delete: Boolean, val create: Boolean) : - UnifiedSharePermission() + data class Custom(var read: Boolean, var edit: Boolean, var delete: Boolean, var create: Boolean) : + UnifiedSharePermission() { + companion object { + fun getFromPermission(permission: UnifiedSharePermission?): Custom { + return Custom( + permission?.customPermissionRead() == true, + permission?.customPermissionEdit() == true, + permission?.customPermissionDelete() == true, + permission?.customPermissionCreate() == true + ) + } + } + } - fun getText(): String { + fun getTextId(): Int { return when(this) { - FileDrop -> "File drop" - CanView -> "Can view" - CanEdit -> "Can edit" - is Custom -> "Custom permissions" + FileDrop -> R.string.share_permission_file_drop + CanView -> R.string.share_permission_can_view + CanEdit -> R.string.share_permission_can_edit + is Custom -> R.string.share_permission_custom } } + + fun customFlag(selector: Custom.() -> Boolean): Boolean = + (this as? Custom)?.selector() ?: false } +fun UnifiedSharePermission?.customPermissionRead(): Boolean = this?.customFlag { read } ?: false +fun UnifiedSharePermission?.customPermissionEdit(): Boolean = this?.customFlag { edit } ?: false +fun UnifiedSharePermission?.customPermissionDelete(): Boolean = this?.customFlag { delete } ?: false +fun UnifiedSharePermission?.customPermissionCreate(): Boolean = this?.customFlag { create } ?: false + +data class CustomPermissionField( + val labelRes: Int, + val getValue: (UnifiedSharePermission.Custom) -> Boolean, + val setValue: (UnifiedSharePermission.Custom, Boolean) -> UnifiedSharePermission.Custom +) + +val customPermissionFields = listOf( + CustomPermissionField( + labelRes = R.string.share_view_view_files_switch, + getValue = { it.read }, + setValue = { p, v -> p.copy(read = v) } + ), + CustomPermissionField( + labelRes = R.string.share_view_edit_files_switch, + getValue = { it.edit }, + setValue = { p, v -> p.copy(edit = v) } + ), + CustomPermissionField( + labelRes = R.string.share_view_create_files_switch, + getValue = { it.create }, + setValue = { p, v -> p.copy(create = v) } + ), + CustomPermissionField( + labelRes = R.string.share_view_delete_files_switch, + getValue = { it.delete }, + setValue = { p, v -> p.copy(delete = v) } + ), +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt index c77094bc..5690cb5f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt @@ -11,19 +11,39 @@ import com.nextcloud.android.common.ui.share.model.api.owner.Owner import com.nextcloud.android.common.ui.share.model.api.user.ShareUser data class UnifiedShare( - val id: String, + val id: String?, val sources: List, val recipients: List, val properties: Map>>, val lastUpdated: Long, - val owner: Owner, + val owner: Owner?, - val permission: UnifiedSharePermission, - val type: UnifiedShareType, + val permission: UnifiedSharePermission?, + val type: UnifiedShareType?, val category: UnifiedShareCategory, val label: String, val note: String = "", val password: String = "", val limit: UnifiedShareDownloadLimit? = null -) +) { + companion object { + fun new(): UnifiedShare { + return UnifiedShare( + id = null, + sources = listOf(), + recipients = listOf(), + properties = mapOf(), + lastUpdated = -1, + owner = null, + permission = null, + type = null, + category = UnifiedShareCategory.Invited, + label = "", + note = "", + password = "", + limit = null + ) + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/SharePreviews.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/SharePreviews.kt deleted file mode 100644 index 53be607c..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/previews/SharePreviews.kt +++ /dev/null @@ -1,790 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.previews - -import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.nextcloud.android.common.ui.share.AnyoneShareContent -import com.nextcloud.android.common.ui.share.CollapsibleSettingsSection -import com.nextcloud.android.common.ui.share.InvitedShareContent -import com.nextcloud.android.common.ui.share.NoteToRecipients -import com.nextcloud.android.common.ui.share.PermissionDropdown -import com.nextcloud.android.common.ui.share.SettingsSwitchRow -import com.nextcloud.android.common.ui.share.ShareActionButtons -import com.nextcloud.android.common.ui.share.ShareBottomSheetHeader -import com.nextcloud.android.common.ui.share.ShareCategoryButtonGroup -import com.nextcloud.android.common.ui.share.ShareViewModel -import com.nextcloud.android.common.ui.share.UnifiedShareView -import com.nextcloud.android.common.ui.share.UnifiedSharesListItem -import com.nextcloud.android.common.ui.share.UnifiedSharesListItemType -import com.nextcloud.android.common.ui.share.model.api.owner.Owner -import com.nextcloud.android.common.ui.share.model.api.user.ShareUser -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareDownloadLimit -import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareType -import com.nextcloud.android.common.ui.share.repository.MockShareRepository - -@Composable -private fun PreviewTheme( - darkTheme: Boolean = false, - content: @Composable () -> Unit -) { - MaterialTheme { - Surface(content = content) - } -} - -@Preview(name = "UnifiedShareView – light", showBackground = true) -@Composable -fun Preview_UnifiedShareView_Light() { - PreviewTheme { - UnifiedShareView(viewModel = ShareViewModel(MockShareRepository())) - } -} - -@Preview(name = "UnifiedShareView – dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -fun Preview_UnifiedShareView_Dark() { - PreviewTheme(darkTheme = true) { - UnifiedShareView(viewModel = ShareViewModel(MockShareRepository())) - } -} - -@Preview(name = "ListItem – Top", showBackground = true, group = "List Item") -@Composable -fun Preview_UnifiedSharesListItem_Top() { - PreviewTheme { - Column(Modifier.padding(8.dp)) { - UnifiedSharesListItemPreviewHelper( - share = previewUserShare(), - type = UnifiedSharesListItemType.Top - ) - } - } -} - -@Preview(name = "ListItem – Mid", showBackground = true, group = "List Item") -@Composable -fun Preview_UnifiedSharesListItem_Mid() { - PreviewTheme { - Column(Modifier.padding(8.dp)) { - UnifiedSharesListItemPreviewHelper( - share = previewGroupShare(), - type = UnifiedSharesListItemType.Mid - ) - } - } -} - -@Preview(name = "ListItem – Bottom", showBackground = true, group = "List Item") -@Composable -fun Preview_UnifiedSharesListItem_Bottom() { - PreviewTheme { - Column(Modifier.padding(8.dp)) { - UnifiedSharesListItemPreviewHelper( - share = previewPublicLinkShare(), - type = UnifiedSharesListItemType.Bottom - ) - } - } -} - -@Preview(name = "ListItem – Single (all three stacked)", showBackground = true, group = "List Item") -@Composable -fun Preview_UnifiedSharesListItem_AllTypes() { - PreviewTheme { - Column(Modifier.padding(8.dp)) { - UnifiedSharesListItemPreviewHelper(previewUserShare(), UnifiedSharesListItemType.Top) - UnifiedSharesListItemPreviewHelper(previewGroupShare(), UnifiedSharesListItemType.Mid) - UnifiedSharesListItemPreviewHelper(previewPublicLinkShare(), UnifiedSharesListItemType.Bottom) - } - } -} - -@Composable -private fun UnifiedSharesListItemPreviewHelper(share: UnifiedShare, type: UnifiedSharesListItemType) { - UnifiedSharesListItem(share = share, type = type) -} - -@Preview(name = "ListItem – CanView permission", showBackground = true, group = "Permissions") -@Composable -fun Preview_ListItem_CanView() { - PreviewTheme { - Column(Modifier.padding(8.dp)) { - UnifiedSharesListItemPreviewHelper( - share = previewUserShare(permission = UnifiedSharePermission.CanView), - type = UnifiedSharesListItemType.Top - ) - } - } -} - -@Preview(name = "ListItem – CanEdit permission", showBackground = true, group = "Permissions") -@Composable -fun Preview_ListItem_CanEdit() { - PreviewTheme { - Column(Modifier.padding(8.dp)) { - UnifiedSharesListItemPreviewHelper( - share = previewUserShare(permission = UnifiedSharePermission.CanEdit), - type = UnifiedSharesListItemType.Top - ) - } - } -} - -@Preview(name = "ListItem – FileDrop permission", showBackground = true, group = "Permissions") -@Composable -fun Preview_ListItem_FileDrop() { - PreviewTheme { - Column(Modifier.padding(8.dp)) { - UnifiedSharesListItemPreviewHelper( - share = previewUserShare(permission = UnifiedSharePermission.FileDrop), - type = UnifiedSharesListItemType.Top - ) - } - } -} - -@Preview(name = "ListItem – Custom permission", showBackground = true, group = "Permissions") -@Composable -fun Preview_ListItem_CustomPermission() { - PreviewTheme { - Column(Modifier.padding(8.dp)) { - UnifiedSharesListItemPreviewHelper( - share = previewUserShare( - permission = UnifiedSharePermission.Custom( - true, - false, - true, - false - ) - ), - type = UnifiedSharesListItemType.Top - ) - } - } -} - -@Preview( - name = "BottomSheet – Invited / default", - showBackground = true, - heightDp = 900, - group = "Bottom Sheet" -) -@Composable -fun Preview_AddShareBottomSheet_Invited() { - PreviewTheme { - AddShareBottomSheetContentPreview( - category = UnifiedShareCategory.Invited, - permission = UnifiedSharePermission.CanView, - searchQuery = "", - note = "", - showSettings = false - ) - } -} - -@Preview( - name = "BottomSheet – Invited / search filled", - showBackground = true, - heightDp = 900, - group = "Bottom Sheet" -) -@Composable -fun Preview_AddShareBottomSheet_InvitedWithSearch() { - PreviewTheme { - AddShareBottomSheetContentPreview( - category = UnifiedShareCategory.Invited, - permission = UnifiedSharePermission.CanEdit, - searchQuery = "alice@nextcloud.example", - note = "Here are the Q2 reports!", - showSettings = false - ) - } -} - -@Preview( - name = "BottomSheet – Invited / settings expanded", - showBackground = true, - heightDp = 1100, - group = "Bottom Sheet" -) -@Composable -fun Preview_AddShareBottomSheet_InvitedSettingsExpanded() { - PreviewTheme { - AddShareBottomSheetContentPreview( - category = UnifiedShareCategory.Invited, - permission = UnifiedSharePermission.CanView, - searchQuery = "bob", - note = "", - showSettings = true - ) - } -} - -@Preview( - name = "BottomSheet – Anyone / default", - showBackground = true, - heightDp = 900, - group = "Bottom Sheet" -) -@Composable -fun Preview_AddShareBottomSheet_Anyone() { - PreviewTheme { - AddShareBottomSheetContentPreview( - category = UnifiedShareCategory.Anyone, - permission = UnifiedSharePermission.CanView, - searchQuery = "", - note = "", - showSettings = false - ) - } -} - -@Preview( - name = "BottomSheet – Anyone / Custom permission (extra switches)", - showBackground = true, - heightDp = 1100, - group = "Bottom Sheet" -) -@Composable -fun Preview_AddShareBottomSheet_AnyoneCustomPermission() { - PreviewTheme { - AddShareBottomSheetContentPreview( - category = UnifiedShareCategory.Anyone, - permission = UnifiedSharePermission.Custom( - true, - false, - false, - false - ), - searchQuery = "", - note = "", - showSettings = false - ) - } -} - -@Preview( - name = "BottomSheet – Anyone / settings expanded", - showBackground = true, - heightDp = 1200, - group = "Bottom Sheet" -) -@Composable -fun Preview_AddShareBottomSheet_AnyoneSettingsExpanded() { - PreviewTheme { - AddShareBottomSheetContentPreview( - category = UnifiedShareCategory.Anyone, - permission = UnifiedSharePermission.CanView, - searchQuery = "", - note = "Public note", - showSettings = true - ) - } -} - -@Preview(name = "CategoryButtons – Invited selected", showBackground = true, group = "Category Buttons") -@Composable -fun Preview_ShareCategoryButtonGroup_Invited() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - ShareCategoryButtonGroup( - selectedCategory = UnifiedShareCategory.Invited, - onCategoryChange = {} - ) - } - } -} - -@Preview(name = "CategoryButtons – Anyone selected", showBackground = true, group = "Category Buttons") -@Composable -fun Preview_ShareCategoryButtonGroup_Anyone() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - ShareCategoryButtonGroup( - selectedCategory = UnifiedShareCategory.Anyone, - onCategoryChange = {} - ) - } - } -} - -@Preview(name = "ActionButtons – Invited / Send disabled", showBackground = true, group = "Action Buttons") -@Composable -fun Preview_ShareActionButtons_InvitedSendDisabled() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - ShareActionButtons( - category = UnifiedShareCategory.Invited, - isSendEnabled = false, - onCopyClick = {}, - onSendClick = {} - ) - } - } -} - -@Preview(name = "ActionButtons – Invited / Send enabled", showBackground = true, group = "Action Buttons") -@Composable -fun Preview_ShareActionButtons_InvitedSendEnabled() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - ShareActionButtons( - category = UnifiedShareCategory.Invited, - isSendEnabled = true, - onCopyClick = {}, - onSendClick = {} - ) - } - } -} - -@Preview(name = "ActionButtons – Anyone", showBackground = true, group = "Action Buttons") -@Composable -fun Preview_ShareActionButtons_Anyone() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - ShareActionButtons( - category = UnifiedShareCategory.Anyone, - isSendEnabled = false, - onCopyClick = {}, - onSendClick = {} - ) - } - } -} - -@Preview(name = "CollapsibleSettings – collapsed", showBackground = true, group = "Settings Section") -@Composable -fun Preview_CollapsibleSettingsSection_Collapsed() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - CollapsibleSettingsSection(isExpanded = false, onToggle = {}) { - InvitedInlineSettingsPreview() - } - } - } -} - -@Preview(name = "CollapsibleSettings – expanded (Invited)", showBackground = true, group = "Settings Section") -@Composable -fun Preview_CollapsibleSettingsSection_ExpandedInvited() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - CollapsibleSettingsSection(isExpanded = true, onToggle = {}) { - InvitedInlineSettingsPreview() - } - } - } -} - -@Preview(name = "CollapsibleSettings – expanded (Anyone)", showBackground = true, group = "Settings Section") -@Composable -fun Preview_CollapsibleSettingsSection_ExpandedAnyone() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - CollapsibleSettingsSection(isExpanded = true, onToggle = {}) { - AnyoneInlineSettingsPreview() - } - } - } -} - -private val allPermissions = listOf( - UnifiedSharePermission.CanView, - UnifiedSharePermission.CanEdit, - UnifiedSharePermission.FileDrop, - UnifiedSharePermission.Custom(false, false, false, false) -) - -@Preview(name = "PermissionDropdown – CanView", showBackground = true, group = "Permission Dropdown") -@Composable -fun Preview_PermissionDropdown_CanView() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - PermissionDropdown( - label = "Participants", - selectedPermission = UnifiedSharePermission.CanView, - availablePermissions = allPermissions, - onPermissionChange = {} - ) - } - } -} - -@Preview(name = "PermissionDropdown – CanEdit", showBackground = true, group = "Permission Dropdown") -@Composable -fun Preview_PermissionDropdown_CanEdit() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - PermissionDropdown( - label = "Anyone with the link", - selectedPermission = UnifiedSharePermission.CanEdit, - availablePermissions = allPermissions, - onPermissionChange = {} - ) - } - } -} - -@Preview(name = "PermissionDropdown – FileDrop", showBackground = true, group = "Permission Dropdown") -@Composable -fun Preview_PermissionDropdown_FileDrop() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - PermissionDropdown( - label = "Anyone with the link", - selectedPermission = UnifiedSharePermission.FileDrop, - availablePermissions = allPermissions, - onPermissionChange = {} - ) - } - } -} - -@Preview(name = "PermissionDropdown – Custom", showBackground = true, group = "Permission Dropdown") -@Composable -fun Preview_PermissionDropdown_Custom() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - PermissionDropdown( - label = "Participants", - selectedPermission = UnifiedSharePermission.Custom(true, false, false, false), - availablePermissions = allPermissions, - onPermissionChange = {} - ) - } - } -} - -@Preview(name = "NoteToRecipients – empty", showBackground = true, group = "Note") -@Composable -fun Preview_NoteToRecipients_Empty() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - NoteToRecipients(note = "", onNoteChange = {}) - } - } -} - -@Preview(name = "NoteToRecipients – with text", showBackground = true, group = "Note") -@Composable -fun Preview_NoteToRecipients_WithText() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - NoteToRecipients(note = "Please review by end of week!", onNoteChange = {}) - } - } -} - -@Preview(name = "InvitedShareContent – empty query", showBackground = true, group = "Invited Content") -@Composable -fun Preview_InvitedShareContent_EmptyQuery() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - InvitedShareContent( - searchQuery = "", - onSearchChange = {}, - permission = UnifiedSharePermission.CanView, - availablePermissions = allPermissions, - onPermissionChange = {} - ) - } - } -} - -@Preview(name = "InvitedShareContent – with query", showBackground = true, group = "Invited Content") -@Composable -fun Preview_InvitedShareContent_WithQuery() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - InvitedShareContent( - searchQuery = "carol@company.org", - onSearchChange = {}, - permission = UnifiedSharePermission.CanEdit, - availablePermissions = allPermissions, - onPermissionChange = {} - ) - } - } -} - -@Preview(name = "AnyoneShareContent – CanView", showBackground = true, group = "Anyone Content") -@Composable -fun Preview_AnyoneShareContent_CanView() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - AnyoneShareContent( - permission = UnifiedSharePermission.CanView, - availablePermissions = allPermissions, - onPermissionChange = {} - ) - } - } -} - -@Preview(name = "AnyoneShareContent – FileDrop", showBackground = true, group = "Anyone Content") -@Composable -fun Preview_AnyoneShareContent_FileDrop() { - PreviewTheme { - Box(Modifier.padding(16.dp)) { - AnyoneShareContent( - permission = UnifiedSharePermission.FileDrop, - availablePermissions = allPermissions, - onPermissionChange = {} - ) - } - } -} - -@Preview(name = "SwitchRow – off", showBackground = true, group = "Switch Row") -@Composable -fun Preview_SettingsSwitchRow_Off() { - PreviewTheme { - Box(Modifier.padding(horizontal = 16.dp)) { - SettingsSwitchRow(label = "Hide download and sync options", checked = false, onCheckedChange = {}) - } - } -} - -@Preview(name = "SwitchRow – on", showBackground = true, group = "Switch Row") -@Composable -fun Preview_SettingsSwitchRow_On() { - PreviewTheme { - Box(Modifier.padding(horizontal = 16.dp)) { - SettingsSwitchRow(label = "Expiration date", checked = true, onCheckedChange = {}) - } - } -} - -@Preview(name = "Item shape – all types", showBackground = true, group = "Shape") -@Composable -fun Preview_ItemShapes_AllTypes() { - PreviewTheme { - Column(Modifier.padding(8.dp)) { - UnifiedSharesListItemType.entries.forEach { type -> - UnifiedSharesListItemPreviewHelper(share = previewUserShare(), type = type) - } - } - } -} - -@Preview( - name = "UnifiedShareView – tablet landscape", - showBackground = true, - widthDp = 840, - heightDp = 600 -) -@Composable -fun Preview_UnifiedShareView_Tablet() { - PreviewTheme { - UnifiedShareView(viewModel = ShareViewModel(MockShareRepository())) - } -} - -@Composable -private fun AddShareBottomSheetContentPreview( - category: UnifiedShareCategory, - permission: UnifiedSharePermission, - searchQuery: String, - note: String, - showSettings: Boolean -) { - val availablePermissions = listOf( - UnifiedSharePermission.CanView, - UnifiedSharePermission.CanEdit, - UnifiedSharePermission.FileDrop, - UnifiedSharePermission.Custom(false, false, false, false) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - ShareBottomSheetHeader(filename = "Abc.txt") - - ShareCategoryButtonGroup( - selectedCategory = category, - onCategoryChange = {} - ) - - if (category == UnifiedShareCategory.Invited) { - InvitedShareContent( - searchQuery = searchQuery, - onSearchChange = {}, - permission = permission, - availablePermissions = availablePermissions, - onPermissionChange = {} - ) - CollapsibleSettingsSection( - isExpanded = showSettings, - onToggle = {} - ) { - InvitedInlineSettingsPreview() - } - } else { - AnyoneShareContent( - permission = permission, - availablePermissions = availablePermissions, - onPermissionChange = {} - ) - if (permission is UnifiedSharePermission.Custom) { - SettingsSwitchRow("View files", false) {} - SettingsSwitchRow("Edit files", false) {} - SettingsSwitchRow("Create files", false) {} - SettingsSwitchRow("Delete files", false) {} - } - CollapsibleSettingsSection( - isExpanded = showSettings, - onToggle = {} - ) { - AnyoneInlineSettingsPreview() - } - } - - NoteToRecipients(note = note, onNoteChange = {}) - - ShareActionButtons( - category = category, - isSendEnabled = searchQuery.isNotBlank(), - onCopyClick = {}, - onSendClick = {} - ) - } -} - -@Composable -private fun InvitedInlineSettingsPreview() { - Column { - SettingsSwitchRow("Share with others", false) {} - SettingsSwitchRow("Edit file", false) {} - SettingsSwitchRow("Expiration date", true) {} - SettingsSwitchRow("Hide download and sync options", false) {} - } -} - -@Composable -private fun AnyoneInlineSettingsPreview() { - Column { - OutlinedTextField( - value = "Public reports link", - onValueChange = {}, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - label = { Text("Label") }, - placeholder = { Text("Optional name for this link") }, - singleLine = true - ) - SettingsSwitchRow("Expiration date", false) {} - SettingsSwitchRow("Password", true) {} - SettingsSwitchRow("Limit downloads", false) {} - SettingsSwitchRow("Hide downloads", false) {} - SettingsSwitchRow("Video verification", false) {} - SettingsSwitchRow("Show files in grid view", true) {} - } -} - -private fun previewUserShare( - permission: UnifiedSharePermission = UnifiedSharePermission.CanView -) = UnifiedShare( - id = "1", - sources = listOf( - ShareUser( - type = "user", - value = "alice@company.com", - displayName = "Alice Smith" - ) - ), - recipients = emptyList(), - properties = emptyMap(), - lastUpdated = 0, - owner = Owner( - userId = "alice", - displayName = "Alice Smith" - ), - label = "Alice Smith", - type = UnifiedShareType.InternalUser, - permission = permission, - category = UnifiedShareCategory.Invited, - note = "", - password = "", - limit = UnifiedShareDownloadLimit(0, 0) -) - -private fun previewGroupShare( - permission: UnifiedSharePermission = UnifiedSharePermission.CanEdit -) = UnifiedShare( - id = "2", - sources = listOf( - ShareUser( - type = "group", - value = "design", - displayName = "Design Team" - ) - ), - recipients = emptyList(), - properties = emptyMap(), - lastUpdated = 0, - owner = Owner( - userId = "system", - displayName = "System" - ), - label = "Design Team", - type = UnifiedShareType.InternalGroup, - permission = permission, - category = UnifiedShareCategory.Invited, - note = "", - password = "", - limit = UnifiedShareDownloadLimit(0, 0) -) - -private fun previewPublicLinkShare( - permission: UnifiedSharePermission = UnifiedSharePermission.FileDrop -) = UnifiedShare( - id = "3", - sources = listOf( - ShareUser( - type = "link", - value = "https://nextcloud.com/s/abc123", - displayName = "Public Link" - ) - ), - recipients = emptyList(), - properties = emptyMap(), - lastUpdated = 1710000000, - owner = Owner( - userId = "system", - displayName = "System" - ), - label = "Public link", - type = UnifiedShareType.ExternalLink, - permission = permission, - category = UnifiedShareCategory.Anyone, - note = "", - password = "1234", - limit = UnifiedShareDownloadLimit(50, 5) -) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml new file mode 100644 index 00000000..f341dc87 --- /dev/null +++ b/ui/src/main/res/values/strings.xml @@ -0,0 +1,60 @@ + + + + Create a new share + Share %s + + File drop + Can view + Can edit + Custom permissions + + View files + Edit files + Create files + Delete files + + Settings + Add people + Name, team, email or federated ID + Participants + + Note to recipients + + Anyone with the link + + Share with others + Edit file + Expiration date + Hide download and sync options + + + Label + Optional name for this link + Expiration date + Password protection + Limit downloads + Hide downloads + Video verification + + Show files in grid view + Copied + Copy link + Send + Create public link + + Edit + Send email + Delete + + Failed to fetch shares + + Share not found, cannot delete + Failed to delete share + + \ No newline at end of file From 1b3daa08fe47f05b258eba40801b7cc1841fa850 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 12:44:48 +0200 Subject: [PATCH 05/95] add translations, bind ui actions Signed-off-by: alperozturk96 --- .../com/nextcloud/android/common/ui/share/ShareView.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index caed3858..f043323d 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -92,10 +92,6 @@ import com.nextcloud.android.common.ui.share.repository.MockShareRepository import kotlinx.coroutines.launch -// TODO: MOVE TO THE ANDROID: COMMON -// TODO: MAKE LAZY COLUMN -// TODO: EXPOSE ACTIONS, IMPLEMENT VIEWMODEL, REPOSITORY TO FETCH ACTUAL SHARE, INJECT NECESSARY PARAMETERS - @Composable private fun ShareView(viewModel: ShareViewModel) { val errorMessageId by viewModel.errorMessageId.collectAsState() @@ -170,7 +166,8 @@ private fun ShareView(viewModel: ShareViewModel) { } } -// TODO: Use like inner tags whenever user add a new people to the search and it should look like User 1, Group 1 etc. +// TODO: Use like inner tags whenever user add a new people to the search and it +// should look like User 1, Group 1 etc. @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AddOrEditShareBottomSheet(title: String, share: UnifiedShare, onDismiss: () -> Unit) { From 736a8f83d6432c458213518ff18d3f59b6432413 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 15:25:47 +0200 Subject: [PATCH 06/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 39 ++++++++++++++++--- .../android/common/ui/share/ShareViewModel.kt | 11 ++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index f043323d..6a2c51ea 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -149,6 +149,9 @@ private fun ShareView(viewModel: ShareViewModel) { AddOrEditShareBottomSheet( title = stringResource(R.string.share_view_bottom_sheet_edit_title, state.share.label), share = state.share, + onCreateOrEdit = { + + }, onDismiss = { bottomSheetState = ShareBottomSheetState.Idle } ) } @@ -158,6 +161,9 @@ private fun ShareView(viewModel: ShareViewModel) { AddOrEditShareBottomSheet( title = stringResource(R.string.share_view_bottom_sheet_new_title), share = state.newShare, + onCreateOrEdit = { + + }, onDismiss = { bottomSheetState = ShareBottomSheetState.Idle } ) } @@ -170,7 +176,12 @@ private fun ShareView(viewModel: ShareViewModel) { // should look like User 1, Group 1 etc. @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun AddOrEditShareBottomSheet(title: String, share: UnifiedShare, onDismiss: () -> Unit) { +private fun AddOrEditShareBottomSheet( + title: String, + share: UnifiedShare, + onCreateOrEdit: () -> Unit, + onDismiss: () -> Unit +) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scrollState = rememberScrollState() @@ -277,7 +288,9 @@ private fun AddOrEditShareBottomSheet(title: String, share: UnifiedShare, onDism clipboard.setClipEntry(clipData.toClipEntry()) } }, - onSendClick = { /* TODO */ } + onSendClick = { + onCreateOrEdit() + } ) } } @@ -451,10 +464,19 @@ private fun InvitedInlineSettings(share: UnifiedShare) { var hasExpiration by remember { mutableStateOf(false) } // TODO var hideDownload by remember { mutableStateOf(false) } // TODO - SettingsSwitchRow(stringResource(R.string.share_view_invited_category_share_with_others_switch), shareWithOthers) { shareWithOthers = it } + SettingsSwitchRow( + stringResource(R.string.share_view_invited_category_share_with_others_switch), + shareWithOthers + ) { shareWithOthers = it } SettingsSwitchRow(stringResource(R.string.share_view_invited_category_edit_file_switch), editFile) { editFile = it } - SettingsSwitchRow(stringResource(R.string.share_view_invited_category_expiration_date_switch), hasExpiration) { hasExpiration = it } - SettingsSwitchRow(stringResource(R.string.share_view_invited_category_hide_and_download_switch), hideDownload) { hideDownload = it } + SettingsSwitchRow( + stringResource(R.string.share_view_invited_category_expiration_date_switch), + hasExpiration + ) { hasExpiration = it } + SettingsSwitchRow( + stringResource(R.string.share_view_invited_category_hide_and_download_switch), + hideDownload + ) { hideDownload = it } } @Composable @@ -655,7 +677,12 @@ private fun UnifiedSharesListItem( HorizontalDivider() DropdownMenuItem( - text = { Text(stringResource(R.string.share_view_list_item_delete), color = MaterialTheme.colorScheme.error) }, + text = { + Text( + stringResource(R.string.share_view_list_item_delete), + color = MaterialTheme.colorScheme.error + ) + }, onClick = { onDeleteShare(share) showContextMenu = false diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 39f948e3..739bb35c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.network.ApiResult +import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare import com.nextcloud.android.common.ui.share.repository.ShareRepository import kotlinx.coroutines.Dispatchers @@ -58,6 +59,16 @@ class ShareViewModel( // endregion // region public methods + fun create(share: UnifiedShare) { + viewModelScope.launch(Dispatchers.IO) { + /* + val request = CreateShareRequest() + repository.createShare() + */ + + } + } + fun delete(share: UnifiedShare) { viewModelScope.launch(Dispatchers.IO) { val id = share.id From 1a78b61e8f9bc0abb301c008389c42dd79b800aa Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 14:02:16 +0200 Subject: [PATCH 07/95] wip Signed-off-by: alperozturk96 # Conflicts: # gradle/verification-metadata.xml # sample/build.gradle --- .../android/common/sample/MainActivity.kt | 11 +++ .../android/common/sample/MainViewModel.kt | 32 +++++++- sample/src/main/res/layout/activity_main.xml | 61 +++++++++++++++ sample/src/main/res/values/strings.xml | 4 + ui/build.gradle | 11 ++- .../common/ui/network/NextcloudCredentials.kt | 14 ++++ .../common/ui/network/NextcloudHttpClient.kt | 74 +++++++++++++++++++ .../common/ui/network/PredefinedStatus.kt | 24 ++++++ .../common/ui/network/UserStatusService.kt | 61 +++++++++++++++ 9 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudCredentials.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudHttpClient.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/PredefinedStatus.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt diff --git a/sample/src/main/java/com/nextcloud/android/common/sample/MainActivity.kt b/sample/src/main/java/com/nextcloud/android/common/sample/MainActivity.kt index ed29a272..f46c2704 100644 --- a/sample/src/main/java/com/nextcloud/android/common/sample/MainActivity.kt +++ b/sample/src/main/java/com/nextcloud/android/common/sample/MainActivity.kt @@ -85,6 +85,17 @@ class MainActivity : AppCompatActivity() { material.colorMaterialButtonPrimaryBorderless(negativeButton) } + binding.testApiBtn.setOnClickListener { + val baseUrl = binding.baseUrl.text?.toString().orEmpty() + val username = binding.username.text?.toString().orEmpty() + val token = binding.token.text?.toString().orEmpty() + mainViewModel.testPredefinedStatuses(baseUrl, username, token) + } + + mainViewModel.apiTestResult.observe(this) { result -> + Toast.makeText(this, result, Toast.LENGTH_LONG).show() + } + setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) mainViewModel.color.observe(this) { applyTheme(it) } diff --git a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt index 457600c7..b69a012a 100644 --- a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt +++ b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt @@ -9,7 +9,37 @@ package com.nextcloud.android.common.sample import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.android.common.ui.network.ApiCredentials +import com.nextcloud.android.common.ui.network.ApiResult +import com.nextcloud.android.common.ui.network.NextcloudHttpClient +import com.nextcloud.android.common.ui.network.UserStatusService +import kotlinx.coroutines.launch class MainViewModel : ViewModel() { val color = MutableLiveData() -} + val apiTestResult = MutableLiveData() + + fun testPredefinedStatuses( + baseUrl: String, + username: String, + token: String + ) { + viewModelScope.launch { + val credentials = ApiCredentials(baseUrl, username, token) + val client = NextcloudHttpClient.create(credentials, enableLogging = true) + val service = UserStatusService(client) + + when (val result = service.fetchPredefinedStatuses()) { + is ApiResult.Success -> + apiTestResult.value = + "✅ Success (${result.data.size} statuses):\n" + + result.data.joinToString("\n") { "${it.icon} ${it.message}" } + + is ApiResult.Error -> + apiTestResult.value = + "❌ Error ${result.error.ocs.meta.statusCode}: ${result.error.ocs.meta.message}" + } + } + } +} \ No newline at end of file diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml index 23447605..2e4f232b 100644 --- a/sample/src/main/res/layout/activity_main.xml +++ b/sample/src/main/res/layout/activity_main.xml @@ -193,6 +193,67 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/circular_progress_bar" /> + + + + + + + + + + + + + + + + + + + + diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 1b957aa2..f7db289e 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -19,6 +19,10 @@ Suggestion Chip Filter Chip Color + Base URL + Username + App token + Test User Status API Theming UI Module \ No newline at end of file diff --git a/ui/build.gradle b/ui/build.gradle index 29a6c512..4393aa5d 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -7,6 +7,7 @@ plugins { id 'org.jetbrains.kotlin.plugin.compose' + id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlinVersion" apply false id 'com.android.library' id 'com.android.built-in-kotlin' id 'com.android.legacy-kapt' @@ -45,8 +46,8 @@ android { } dependencies { - implementation 'androidx.compose.ui:ui-tooling-preview:1.10.6' - debugImplementation 'androidx.compose.ui:ui-tooling:1.10.6' + implementation 'androidx.compose.ui:ui-tooling-preview:1.11.0' + debugImplementation 'androidx.compose.ui:ui-tooling:1.11.0' kapt "org.jetbrains.kotlin:kotlin-metadata-jvm:${kotlinVersion}" implementation(platform("androidx.compose:compose-bom:2026.06.01")) @@ -63,6 +64,12 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' + implementation(platform("com.squareup.okhttp3:okhttp-bom:5.3.2")) + implementation("com.squareup.okhttp3:okhttp") + implementation("com.squareup.okhttp3:logging-interceptor") + + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") + implementation project(':core') api project(':material-color-utilities') diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudCredentials.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudCredentials.kt new file mode 100644 index 00000000..4194b679 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudCredentials.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network + +data class ApiCredentials( + val baseURL: String, + val username: String, + val token: String +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudHttpClient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudHttpClient.kt new file mode 100644 index 00000000..54eb8e9b --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudHttpClient.kt @@ -0,0 +1,74 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network + +import okhttp3.Credentials +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.logging.HttpLoggingInterceptor +import java.util.concurrent.TimeUnit + +class NextcloudHttpClient private constructor( + val okHttpClient: OkHttpClient, + val credentials: ApiCredentials +) { + companion object { + private const val CONNECT_TIMEOUT_SECONDS = 30L + private const val READ_TIMEOUT_SECONDS = 30L + private const val WRITE_TIMEOUT_SECONDS = 30L + + fun create( + credentials: ApiCredentials, + enableLogging: Boolean = false + ): NextcloudHttpClient { + val authInterceptor = AuthInterceptor(credentials) + + val loggingInterceptor = HttpLoggingInterceptor().apply { + level = if (enableLogging) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + } + + val okHttpClient = OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .addInterceptor(authInterceptor) + .addInterceptor(loggingInterceptor) + .build() + + return NextcloudHttpClient(okHttpClient, credentials) + } + } + + private class AuthInterceptor(private val credentials: ApiCredentials) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val basicCredentials = Credentials.basic(credentials.username, credentials.token) + + val request = chain.request() + .newBuilder() + .header("Authorization", basicCredentials) + .header("OCS-APIRequest", "true") + .url(buildUrl(chain.request().url.toString(), credentials.baseURL)) + .build() + + return chain.proceed(request) + } + + private fun buildUrl(requestUrl: String, baseUrl: String): String { + return if (requestUrl.startsWith("http://") || requestUrl.startsWith("https://")) { + requestUrl + } else { + "${baseUrl.trimEnd('/')}/${requestUrl.trimStart('/')}" + } + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/PredefinedStatus.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/PredefinedStatus.kt new file mode 100644 index 00000000..35918558 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/PredefinedStatus.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network + +import kotlinx.serialization.Serializable + +@Serializable +data class ClearAt( + val type: String, + val time: Int +) + +@Serializable +data class PredefinedStatus( + val id: String, + val icon: String, + val message: String, + val clearAt: ClearAt? = null +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt new file mode 100644 index 00000000..7d466eef --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.Request + +class UserStatusService(private val client: NextcloudHttpClient) { + + private val json = Json { ignoreUnknownKeys = true } + + suspend fun fetchPredefinedStatuses(): ApiResult> = + withContext(Dispatchers.IO) { + val url = + "${client.credentials.baseURL.trimEnd('/')}" + + "/ocs/v2.php/apps/user_status/api/v1/predefined_statuses" + + val request = + Request + .Builder() + .url(url) + .header("Accept", "application/json") + .build() + + try { + val response = client.okHttpClient.newCall(request).execute() + val body = response.body?.string().orEmpty() + + if (response.isSuccessful) { + val parsed = json.decodeFromString>>(body) + ApiResult.Success(parsed.ocs.data) + } else { + val error = json.decodeFromString>(body) + ApiResult.Error(error) + } + } catch (e: Exception) { + ApiResult.Error( + OcsResponse( + Ocs( + meta = + Meta( + status = "error", + statusCode = -1, + message = e.message ?: "Unknown error", + totalItems = "", + itemsPerPage = "" + ), + data = e.message ?: "Unknown error" + ) + ) + ) + } + } +} From 1a9d79631c9994061fb43a81f15b91a3075e9ab0 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Apr 2026 10:39:10 +0200 Subject: [PATCH 08/95] add test ocs call Signed-off-by: alperozturk96 --- .../com/nextcloud/android/common/sample/MainViewModel.kt | 8 ++++---- .../android/common/ui/network/UserStatusService.kt | 7 ++++++- .../{NextcloudCredentials.kt => api/ApiCredentials.kt} | 2 +- .../{NextcloudHttpClient.kt => api/ApiHttpClient.kt} | 8 ++++---- .../android/common/ui/network/{ => model}/ApiResult.kt | 2 +- .../android/common/ui/network/{ => model}/OcsResponse.kt | 2 +- .../nextcloud/android/common/ui/share/ShareViewModel.kt | 3 +-- .../common/ui/share/repository/MockShareRepository.kt | 2 +- .../common/ui/share/repository/ShareRemoteRepository.kt | 2 +- .../android/common/ui/share/repository/ShareRepository.kt | 2 +- 10 files changed, 21 insertions(+), 17 deletions(-) rename ui/src/main/java/com/nextcloud/android/common/ui/network/{NextcloudCredentials.kt => api/ApiCredentials.kt} (83%) rename ui/src/main/java/com/nextcloud/android/common/ui/network/{NextcloudHttpClient.kt => api/ApiHttpClient.kt} (92%) rename ui/src/main/java/com/nextcloud/android/common/ui/network/{ => model}/ApiResult.kt (85%) rename ui/src/main/java/com/nextcloud/android/common/ui/network/{ => model}/OcsResponse.kt (92%) diff --git a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt index b69a012a..220effdc 100644 --- a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt +++ b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt @@ -10,9 +10,9 @@ package com.nextcloud.android.common.sample import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.nextcloud.android.common.ui.network.ApiCredentials -import com.nextcloud.android.common.ui.network.ApiResult -import com.nextcloud.android.common.ui.network.NextcloudHttpClient +import com.nextcloud.android.common.ui.network.api.ApiCredentials +import com.nextcloud.android.common.ui.network.model.ApiResult +import com.nextcloud.android.common.ui.network.api.ApiHttpClient import com.nextcloud.android.common.ui.network.UserStatusService import kotlinx.coroutines.launch @@ -27,7 +27,7 @@ class MainViewModel : ViewModel() { ) { viewModelScope.launch { val credentials = ApiCredentials(baseUrl, username, token) - val client = NextcloudHttpClient.create(credentials, enableLogging = true) + val client = ApiHttpClient.create(credentials, enableLogging = true) val service = UserStatusService(client) when (val result = service.fetchPredefinedStatuses()) { diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt index 7d466eef..9fa314ee 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt @@ -7,12 +7,17 @@ package com.nextcloud.android.common.ui.network +import com.nextcloud.android.common.ui.network.api.ApiHttpClient +import com.nextcloud.android.common.ui.network.model.ApiResult +import com.nextcloud.android.common.ui.network.model.Meta +import com.nextcloud.android.common.ui.network.model.Ocs +import com.nextcloud.android.common.ui.network.model.OcsResponse import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.Request -class UserStatusService(private val client: NextcloudHttpClient) { +class UserStatusService(private val client: ApiHttpClient) { private val json = Json { ignoreUnknownKeys = true } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudCredentials.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiCredentials.kt similarity index 83% rename from ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudCredentials.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiCredentials.kt index 4194b679..f6770090 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudCredentials.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiCredentials.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.network +package com.nextcloud.android.common.ui.network.api data class ApiCredentials( val baseURL: String, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudHttpClient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt similarity index 92% rename from ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudHttpClient.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt index 54eb8e9b..499207a8 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/NextcloudHttpClient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.network +package com.nextcloud.android.common.ui.network.api import okhttp3.Credentials import okhttp3.Interceptor @@ -14,7 +14,7 @@ import okhttp3.Response import okhttp3.logging.HttpLoggingInterceptor import java.util.concurrent.TimeUnit -class NextcloudHttpClient private constructor( +class ApiHttpClient private constructor( val okHttpClient: OkHttpClient, val credentials: ApiCredentials ) { @@ -26,7 +26,7 @@ class NextcloudHttpClient private constructor( fun create( credentials: ApiCredentials, enableLogging: Boolean = false - ): NextcloudHttpClient { + ): ApiHttpClient { val authInterceptor = AuthInterceptor(credentials) val loggingInterceptor = HttpLoggingInterceptor().apply { @@ -45,7 +45,7 @@ class NextcloudHttpClient private constructor( .addInterceptor(loggingInterceptor) .build() - return NextcloudHttpClient(okHttpClient, credentials) + return ApiHttpClient(okHttpClient, credentials) } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/ApiResult.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/ApiResult.kt similarity index 85% rename from ui/src/main/java/com/nextcloud/android/common/ui/network/ApiResult.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/network/model/ApiResult.kt index 1827178f..9ab9328b 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/ApiResult.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/ApiResult.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.network +package com.nextcloud.android.common.ui.network.model sealed class ApiResult { data class Success(val data: T) : ApiResult() diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/OcsResponse.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt similarity index 92% rename from ui/src/main/java/com/nextcloud/android/common/ui/network/OcsResponse.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt index 1dc0e7d9..d0c361e5 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/OcsResponse.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.network +package com.nextcloud.android.common.ui.network.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 739bb35c..5621326e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -10,8 +10,7 @@ package com.nextcloud.android.common.ui.share import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.android.common.ui.R -import com.nextcloud.android.common.ui.network.ApiResult -import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest +import com.nextcloud.android.common.ui.network.model.ApiResult import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare import com.nextcloud.android.common.ui.share.repository.ShareRepository import kotlinx.coroutines.Dispatchers diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt index 73aa1227..053d951c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -7,7 +7,7 @@ package com.nextcloud.android.common.ui.share.repository -import com.nextcloud.android.common.ui.network.ApiResult +import com.nextcloud.android.common.ui.network.model.ApiResult import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse import com.nextcloud.android.common.ui.share.model.api.owner.Owner diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index bea010e3..6551f27f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -7,7 +7,7 @@ package com.nextcloud.android.common.ui.share.repository -import com.nextcloud.android.common.ui.network.ApiResult +import com.nextcloud.android.common.ui.network.model.ApiResult import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt index c6bb626d..3267d9db 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt @@ -7,7 +7,7 @@ package com.nextcloud.android.common.ui.share.repository -import com.nextcloud.android.common.ui.network.ApiResult +import com.nextcloud.android.common.ui.network.model.ApiResult import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients From 5221d7c9a8644f9ee963400d02b73175fd6faaeb Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 14:11:09 +0200 Subject: [PATCH 09/95] fix test call Signed-off-by: alperozturk96 --- sample/src/main/AndroidManifest.xml | 1 + ui/build.gradle | 2 +- .../common/ui/network/PredefinedStatus.kt | 21 ++++++++++++++++++- .../common/ui/network/UserStatusService.kt | 4 ++-- .../common/ui/network/model/OcsResponse.kt | 4 ++-- 5 files changed, 26 insertions(+), 6 deletions(-) diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index bc5dab68..153a8ae0 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" + android:usesCleartextTraffic="true" android:theme="@style/Theme.Androidcommon"> { + override val descriptor = PrimitiveSerialDescriptor("ClearAtTime", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String { + val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString() + return (jsonDecoder.decodeJsonElement() as? JsonPrimitive)?.content ?: "" + } + + override fun serialize(encoder: Encoder, value: String) = encoder.encodeString(value) +} @Serializable data class ClearAt( val type: String, - val time: Int + @Serializable(with = ClearAtTimeSerializer::class) + val time: String ) @Serializable diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt index 9fa314ee..73f9a3a0 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt @@ -24,7 +24,7 @@ class UserStatusService(private val client: ApiHttpClient) { suspend fun fetchPredefinedStatuses(): ApiResult> = withContext(Dispatchers.IO) { val url = - "${client.credentials.baseURL.trimEnd('/')}" + + client.credentials.baseURL.trimEnd('/') + "/ocs/v2.php/apps/user_status/api/v1/predefined_statuses" val request = @@ -36,7 +36,7 @@ class UserStatusService(private val client: ApiHttpClient) { try { val response = client.okHttpClient.newCall(request).execute() - val body = response.body?.string().orEmpty() + val body = response.body.string() if (response.isSuccessful) { val parsed = json.decodeFromString>>(body) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt index d0c361e5..0b5c39db 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt @@ -31,8 +31,8 @@ data class Meta( val message: String, @SerialName("totalitems") - val totalItems: String, + val totalItems: String = "", @SerialName("itemsperpage") - val itemsPerPage: String + val itemsPerPage: String = "" ) From c9cb52a114d8978ff5b0aed804934df954efb866 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 14:56:55 +0200 Subject: [PATCH 10/95] use client Signed-off-by: alperozturk96 --- .../com/nextcloud/android/common/ui/share/ShareView.kt | 9 ++++++--- .../common/ui/share/repository/ShareRemoteRepository.kt | 5 ++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 6a2c51ea..69eae020 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -88,7 +88,10 @@ import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission import com.nextcloud.android.common.ui.share.model.ui.customPermissionFields +import com.nextcloud.android.common.ui.network.api.ApiCredentials +import com.nextcloud.android.common.ui.network.api.ApiHttpClient import com.nextcloud.android.common.ui.share.repository.MockShareRepository +import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch @@ -723,9 +726,9 @@ private fun PreviewTheme( } } -fun ComposeView.setupUnifiedShare(colorScheme: ColorScheme) { - // TODO: REPLACE - val viewModel = ShareViewModel(repository = MockShareRepository()) +fun ComposeView.setupUnifiedShare(colorScheme: ColorScheme, credentials: ApiCredentials) { + val apiHttpClient = ApiHttpClient.create(credentials) + val viewModel = ShareViewModel(repository = ShareRemoteRepository(apiHttpClient)) setContent { MaterialTheme( diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index 6551f27f..e90ec820 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -7,6 +7,7 @@ package com.nextcloud.android.common.ui.share.repository +import com.nextcloud.android.common.ui.network.api.ApiHttpClient import com.nextcloud.android.common.ui.network.model.ApiResult import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse @@ -14,9 +15,7 @@ import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipient import com.nextcloud.android.common.ui.share.model.api.update.UpdateShareRequest import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare -class ShareRemoteRepository: ShareRepository { - - // TODO: ALL OCS-APIRequest //boolean header +class ShareRemoteRepository(private val client: ApiHttpClient) : ShareRepository { /** * Searches for recipients From cebaf334024bb231df00d0640ce59f20e7f9ce05 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 16:16:59 +0200 Subject: [PATCH 11/95] wip Signed-off-by: alperozturk96 --- .../android/common/sample/MainViewModel.kt | 2 +- .../common/ui/network/api/ApiHttpClient.kt | 31 ++- .../common/ui/network/api/ApiMethod.kt | 15 ++ .../share/repository/ShareRemoteRepository.kt | 185 ++++++++++++++---- 4 files changed, 186 insertions(+), 47 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiMethod.kt diff --git a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt index 220effdc..6725e64c 100644 --- a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt +++ b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt @@ -42,4 +42,4 @@ class MainViewModel : ViewModel() { } } } -} \ No newline at end of file +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt index 499207a8..b484d09f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt @@ -10,6 +10,7 @@ package com.nextcloud.android.common.ui.network.api import okhttp3.Credentials import okhttp3.Interceptor import okhttp3.OkHttpClient +import okhttp3.Request import okhttp3.Response import okhttp3.logging.HttpLoggingInterceptor import java.util.concurrent.TimeUnit @@ -19,9 +20,20 @@ class ApiHttpClient private constructor( val credentials: ApiCredentials ) { companion object { - private const val CONNECT_TIMEOUT_SECONDS = 30L - private const val READ_TIMEOUT_SECONDS = 30L - private const val WRITE_TIMEOUT_SECONDS = 30L + private const val CONNECT_TIMEOUT_SECONDS = 90L + private const val READ_TIMEOUT_SECONDS = 90L + private const val WRITE_TIMEOUT_SECONDS = 90L + + private const val HTTP_PREFIX = "http://" + private const val HTTPS_PREFIX = "https://" + + private const val HEADER_ACCEPT_NAME = "Accept" + private const val HEADER_ACCEPT_VALUE = "application/json" + + private const val HEADER_AUTHORIZATION_NAME = "Authorization" + + private const val HEADER_OCS_NAME = "OCS-APIRequest" + private const val HEADER_OCS_VALUE = "true" fun create( credentials: ApiCredentials, @@ -49,14 +61,21 @@ class ApiHttpClient private constructor( } } + fun buildRequest(url: String, method: ApiMethod, body: okhttp3.RequestBody? = null): Request = + Request.Builder() + .url(url) + .header(HEADER_ACCEPT_NAME, HEADER_ACCEPT_VALUE) + .method(method.type, body) + .build() + private class AuthInterceptor(private val credentials: ApiCredentials) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val basicCredentials = Credentials.basic(credentials.username, credentials.token) val request = chain.request() .newBuilder() - .header("Authorization", basicCredentials) - .header("OCS-APIRequest", "true") + .header(HEADER_AUTHORIZATION_NAME, basicCredentials) + .header(HEADER_OCS_NAME, HEADER_OCS_VALUE) .url(buildUrl(chain.request().url.toString(), credentials.baseURL)) .build() @@ -64,7 +83,7 @@ class ApiHttpClient private constructor( } private fun buildUrl(requestUrl: String, baseUrl: String): String { - return if (requestUrl.startsWith("http://") || requestUrl.startsWith("https://")) { + return if (requestUrl.startsWith(HTTP_PREFIX) || requestUrl.startsWith(HTTPS_PREFIX)) { requestUrl } else { "${baseUrl.trimEnd('/')}/${requestUrl.trimStart('/')}" diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiMethod.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiMethod.kt new file mode 100644 index 00000000..45e56478 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiMethod.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network.api + +enum class ApiMethod(val type: String) { + GET("GET"), + POST("POST"), + PUT("PUT"), + DELETE("DELETE") +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index e90ec820..2d6eac2a 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -8,15 +8,48 @@ package com.nextcloud.android.common.ui.share.repository import com.nextcloud.android.common.ui.network.api.ApiHttpClient +import com.nextcloud.android.common.ui.network.api.ApiMethod import com.nextcloud.android.common.ui.network.model.ApiResult +import com.nextcloud.android.common.ui.network.model.Meta +import com.nextcloud.android.common.ui.network.model.Ocs +import com.nextcloud.android.common.ui.network.model.OcsResponse import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse +import com.nextcloud.android.common.ui.share.model.api.create.toUnifiedShare import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients import com.nextcloud.android.common.ui.share.model.api.update.UpdateShareRequest import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody class ShareRemoteRepository(private val client: ApiHttpClient) : ShareRepository { + companion object { + private const val SHARE_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/share/" + private const val SHARES_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/shares" + } + + private val json = Json { ignoreUnknownKeys = true } + private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + private val baseUrl get() = client.credentials.baseURL.trimEnd('/') + + private fun errorResult(e: Exception): ApiResult.Error = + ApiResult.Error( + OcsResponse( + Ocs( + meta = Meta( + status = "error", + statusCode = -1, + message = e.message ?: "Unknown error" + ), + data = e.message ?: "Unknown error" + ) + ) + ) + /** * Searches for recipients */ @@ -25,47 +58,104 @@ class ShareRemoteRepository(private val client: ApiHttpClient) : ShareRepository query: String, limit: Int, offset: Int - ): ApiResult> { - /* - GET - /ocs/v2.php/apps/sharing/api/v1/recipients + ): ApiResult> = withContext(Dispatchers.IO) { + val url = "$baseUrl/ocs/v2.php/apps/sharing/api/v1/recipients" + + "?recipientType=$recipientType&query=$query&limit=$limit&offset=$offset" - */ + val request = client.buildRequest(url, ApiMethod.GET) - TODO("Not yet implemented") + try { + val response = client.okHttpClient.newCall(request).execute() + val body = response.body.string() + if (response.isSuccessful) { + val parsed = json.decodeFromString>>(body) + ApiResult.Success(parsed.ocs.data) + } else { + ApiResult.Error(json.decodeFromString>(body)) + } + } catch (e: Exception) { + errorResult(e) + } } - override suspend fun createShare(request: CreateShareRequest): ApiResult { - /* - POST - /ocs/v2.php/apps/sharing/api/v1/share - */ - TODO("Not yet implemented") - } + override suspend fun createShare(request: CreateShareRequest): ApiResult = + withContext(Dispatchers.IO) { + val url = "$baseUrl$SHARE_ENDPOINT" + val body = json.encodeToString(request).toRequestBody(jsonMediaType) - override suspend fun fetchShare(id: String): ApiResult { - /* - POST - /ocs/v2.php/apps/sharing/api/v1/share/{id} - */ - TODO("Not yet implemented") - } + val httpRequest = client.buildRequest(url, ApiMethod.POST, body) - override suspend fun updateShare(id: String, request: UpdateShareRequest): ApiResult { - /* - PUT - /ocs/v2.php/apps/sharing/api/v1/share/{id} - */ - TODO("Not yet implemented") - } + try { + val response = client.okHttpClient.newCall(httpRequest).execute() + val responseBody = response.body.string() + if (response.isSuccessful) { + val parsed = json.decodeFromString>(responseBody) + ApiResult.Success(parsed.ocs.data) + } else { + ApiResult.Error(json.decodeFromString>(responseBody)) + } + } catch (e: Exception) { + errorResult(e) + } + } - override suspend fun deleteShare(id: String): ApiResult { - /* - DELETE - /ocs/v2.php/apps/sharing/api/v1/share/{id} - */ - TODO("Not yet implemented") - } + override suspend fun fetchShare(id: String): ApiResult = + withContext(Dispatchers.IO) { + val url = "$baseUrl$SHARE_ENDPOINT$id" + val request = client.buildRequest(url, ApiMethod.GET) + + try { + val response = client.okHttpClient.newCall(request).execute() + val body = response.body.string() + if (response.isSuccessful) { + val parsed = json.decodeFromString>(body) + ApiResult.Success(parsed.ocs.data) + } else { + ApiResult.Error(json.decodeFromString>(body)) + } + } catch (e: Exception) { + errorResult(e) + } + } + + override suspend fun updateShare(id: String, request: UpdateShareRequest): ApiResult = + withContext(Dispatchers.IO) { + val url = "$baseUrl$SHARE_ENDPOINT$id" + val body = json.encodeToString(request).toRequestBody(jsonMediaType) + + val httpRequest = client.buildRequest(url, ApiMethod.PUT, body) + + try { + val response = client.okHttpClient.newCall(httpRequest).execute() + val responseBody = response.body.string() + if (response.isSuccessful) { + val parsed = json.decodeFromString>(responseBody) + ApiResult.Success(parsed.ocs.data) + } else { + ApiResult.Error(json.decodeFromString>(responseBody)) + } + } catch (e: Exception) { + errorResult(e) + } + } + + override suspend fun deleteShare(id: String): ApiResult = + withContext(Dispatchers.IO) { + val url = "$baseUrl$SHARE_ENDPOINT$id" + val request = client.buildRequest(url, ApiMethod.DELETE) + + try { + val response = client.okHttpClient.newCall(request).execute() + if (response.isSuccessful) { + ApiResult.Success(Unit) + } else { + val body = response.body.string() + ApiResult.Error(json.decodeFromString>(body)) + } + } catch (e: Exception) { + errorResult(e) + } + } /** * @param sourceType @@ -81,11 +171,26 @@ class ShareRemoteRepository(private val client: ApiHttpClient) : ShareRepository sourceType: String?, lastShareId: String?, limit: Int - ): ApiResult> { - /* - GET - /ocs/v2.php/apps/sharing/api/v1/shares - */ - TODO("Not yet implemented") + ): ApiResult> = withContext(Dispatchers.IO) { + val queryParams = buildString { + append("?limit=$limit") + sourceType?.let { append("&sourceType=$it") } + lastShareId?.let { append("&lastShareId=$it") } + } + val url = "$baseUrl$SHARES_ENDPOINT$queryParams" + val request = client.buildRequest(url, ApiMethod.GET) + + try { + val response = client.okHttpClient.newCall(request).execute() + val body = response.body.string() + if (response.isSuccessful) { + val parsed = json.decodeFromString>>(body) + ApiResult.Success(parsed.ocs.data.map { it.toUnifiedShare() }) + } else { + ApiResult.Error(json.decodeFromString>(body)) + } + } catch (e: Exception) { + errorResult(e) + } } } From 266fcaa7226385a14d611cfaa3f69878dcfb59cd Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 16:20:32 +0200 Subject: [PATCH 12/95] wip Signed-off-by: alperozturk96 --- .../share/repository/ShareRemoteRepository.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index 2d6eac2a..3a894ed4 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -59,6 +59,8 @@ class ShareRemoteRepository(private val client: ApiHttpClient) : ShareRepository limit: Int, offset: Int ): ApiResult> = withContext(Dispatchers.IO) { + // TODO: - CHECK URL GET + // /ocs/v2.php/apps/sharing/api/v1/recipients val url = "$baseUrl/ocs/v2.php/apps/sharing/api/v1/recipients" + "?recipientType=$recipientType&query=$query&limit=$limit&offset=$offset" @@ -80,6 +82,10 @@ class ShareRemoteRepository(private val client: ApiHttpClient) : ShareRepository override suspend fun createShare(request: CreateShareRequest): ApiResult = withContext(Dispatchers.IO) { + /* + POST + /ocs/v2.php/apps/sharing/api/v1/share + */ val url = "$baseUrl$SHARE_ENDPOINT" val body = json.encodeToString(request).toRequestBody(jsonMediaType) @@ -101,6 +107,10 @@ class ShareRemoteRepository(private val client: ApiHttpClient) : ShareRepository override suspend fun fetchShare(id: String): ApiResult = withContext(Dispatchers.IO) { + /* + POST + /ocs/v2.php/apps/sharing/api/v1/share/{id} + */ val url = "$baseUrl$SHARE_ENDPOINT$id" val request = client.buildRequest(url, ApiMethod.GET) @@ -120,6 +130,10 @@ class ShareRemoteRepository(private val client: ApiHttpClient) : ShareRepository override suspend fun updateShare(id: String, request: UpdateShareRequest): ApiResult = withContext(Dispatchers.IO) { + /* + PUT + /ocs/v2.php/apps/sharing/api/v1/share/{id} + */ val url = "$baseUrl$SHARE_ENDPOINT$id" val body = json.encodeToString(request).toRequestBody(jsonMediaType) @@ -141,6 +155,10 @@ class ShareRemoteRepository(private val client: ApiHttpClient) : ShareRepository override suspend fun deleteShare(id: String): ApiResult = withContext(Dispatchers.IO) { + /* + DELETE + /ocs/v2.php/apps/sharing/api/v1/share/{id} + */ val url = "$baseUrl$SHARE_ENDPOINT$id" val request = client.buildRequest(url, ApiMethod.DELETE) @@ -172,6 +190,10 @@ class ShareRemoteRepository(private val client: ApiHttpClient) : ShareRepository lastShareId: String?, limit: Int ): ApiResult> = withContext(Dispatchers.IO) { + /* + GET + /ocs/v2.php/apps/sharing/api/v1/shares + */ val queryParams = buildString { append("?limit=$limit") sourceType?.let { append("&sourceType=$it") } From e84f1522a0e459328c05d61f617fb1f96c188d18 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 16:21:37 +0200 Subject: [PATCH 13/95] wip Signed-off-by: alperozturk96 --- .../nextcloud/android/common/ui/network/api/ApiHttpClient.kt | 1 + .../common/ui/share/repository/ShareRemoteRepository.kt | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt index b484d09f..81278099 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt @@ -86,6 +86,7 @@ class ApiHttpClient private constructor( return if (requestUrl.startsWith(HTTP_PREFIX) || requestUrl.startsWith(HTTPS_PREFIX)) { requestUrl } else { + // TODO - remove hardcoded value? "${baseUrl.trimEnd('/')}/${requestUrl.trimStart('/')}" } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index 3a894ed4..0a192145 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -33,9 +33,14 @@ class ShareRemoteRepository(private val client: ApiHttpClient) : ShareRepository } private val json = Json { ignoreUnknownKeys = true } + + // TODO - move from here? private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + // TODO - trim end? private val baseUrl get() = client.credentials.baseURL.trimEnd('/') + // TODO - Move from here private fun errorResult(e: Exception): ApiResult.Error = ApiResult.Error( OcsResponse( From cf6a8b48594d9fa91ff5eb3f7eb89f7f6c4b1dbb Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 6 May 2026 08:38:51 +0200 Subject: [PATCH 14/95] wip Signed-off-by: alperozturk96 --- .../android/common/sample/MainViewModel.kt | 18 +- .../common/ui/network/UserStatusRepository.kt | 30 +++ .../common/ui/network/UserStatusService.kt | 66 ----- .../common/ui/network/api/ApiHttpClient.kt | 94 -------- .../common/ui/network/auth/AuthInterceptor.kt | 49 ++++ .../ServerCredentials.kt} | 6 +- .../{api/ApiMethod.kt => http/HttpMethod.kt} | 6 +- .../ui/network/http/NextcloudHttpClient.kt | 76 ++++++ .../common/ui/network/http/RequestBuilder.kt | 27 +++ .../common/ui/network/model/ApiResult.kt | 13 - .../common/ui/network/model/NetworkResult.kt | 22 ++ .../ui/network/serialization/OCSSerializer.kt | 14 ++ .../android/common/ui/share/ShareView.kt | 10 +- .../android/common/ui/share/ShareViewModel.kt | 8 +- .../share/repository/MockShareRepository.kt | 26 +- .../share/repository/ShareRemoteRepository.kt | 226 ++++-------------- .../ui/share/repository/ShareRepository.kt | 14 +- 17 files changed, 314 insertions(+), 391 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusRepository.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/auth/AuthInterceptor.kt rename ui/src/main/java/com/nextcloud/android/common/ui/network/{api/ApiCredentials.kt => auth/ServerCredentials.kt} (72%) rename ui/src/main/java/com/nextcloud/android/common/ui/network/{api/ApiMethod.kt => http/HttpMethod.kt} (70%) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/http/RequestBuilder.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/model/ApiResult.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/serialization/OCSSerializer.kt diff --git a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt index 6725e64c..0738ab44 100644 --- a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt +++ b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt @@ -10,10 +10,10 @@ package com.nextcloud.android.common.sample import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.nextcloud.android.common.ui.network.api.ApiCredentials -import com.nextcloud.android.common.ui.network.model.ApiResult -import com.nextcloud.android.common.ui.network.api.ApiHttpClient -import com.nextcloud.android.common.ui.network.UserStatusService +import com.nextcloud.android.common.ui.network.auth.ServerCredentials +import com.nextcloud.android.common.ui.network.model.NetworkResult +import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient +import com.nextcloud.android.common.ui.network.UserStatusRepository import kotlinx.coroutines.launch class MainViewModel : ViewModel() { @@ -26,17 +26,17 @@ class MainViewModel : ViewModel() { token: String ) { viewModelScope.launch { - val credentials = ApiCredentials(baseUrl, username, token) - val client = ApiHttpClient.create(credentials, enableLogging = true) - val service = UserStatusService(client) + val credentials = ServerCredentials(baseUrl, username, token) + val client = NextcloudHttpClient.create(credentials, enableLogging = true) + val service = UserStatusRepository(client) when (val result = service.fetchPredefinedStatuses()) { - is ApiResult.Success -> + is NetworkResult.Success -> apiTestResult.value = "✅ Success (${result.data.size} statuses):\n" + result.data.joinToString("\n") { "${it.icon} ${it.message}" } - is ApiResult.Error -> + is NetworkResult.Error -> apiTestResult.value = "❌ Error ${result.error.ocs.meta.statusCode}: ${result.error.ocs.meta.message}" } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusRepository.kt new file mode 100644 index 00000000..7c36bdf9 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusRepository.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network + +import com.nextcloud.android.common.ui.network.http.HttpMethod +import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient +import com.nextcloud.android.common.ui.network.model.NetworkResult +import com.nextcloud.android.common.ui.network.model.OcsResponse +import com.nextcloud.android.common.ui.network.serialization.OCSSerializer + +class UserStatusRepository(private val client: NextcloudHttpClient) { + + private companion object { + private const val PREDEFINED_STATUSES_ENDPOINT = + "/ocs/v2.php/apps/user_status/api/v1/predefined_statuses" + } + + suspend fun fetchPredefinedStatuses(): NetworkResult> = + client.executeRequest( + endpoint = PREDEFINED_STATUSES_ENDPOINT, + method = HttpMethod.GET + ) { body -> + OCSSerializer.json.decodeFromString>>(body).ocs.data + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt deleted file mode 100644 index 73f9a3a0..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.network - -import com.nextcloud.android.common.ui.network.api.ApiHttpClient -import com.nextcloud.android.common.ui.network.model.ApiResult -import com.nextcloud.android.common.ui.network.model.Meta -import com.nextcloud.android.common.ui.network.model.Ocs -import com.nextcloud.android.common.ui.network.model.OcsResponse -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import okhttp3.Request - -class UserStatusService(private val client: ApiHttpClient) { - - private val json = Json { ignoreUnknownKeys = true } - - suspend fun fetchPredefinedStatuses(): ApiResult> = - withContext(Dispatchers.IO) { - val url = - client.credentials.baseURL.trimEnd('/') + - "/ocs/v2.php/apps/user_status/api/v1/predefined_statuses" - - val request = - Request - .Builder() - .url(url) - .header("Accept", "application/json") - .build() - - try { - val response = client.okHttpClient.newCall(request).execute() - val body = response.body.string() - - if (response.isSuccessful) { - val parsed = json.decodeFromString>>(body) - ApiResult.Success(parsed.ocs.data) - } else { - val error = json.decodeFromString>(body) - ApiResult.Error(error) - } - } catch (e: Exception) { - ApiResult.Error( - OcsResponse( - Ocs( - meta = - Meta( - status = "error", - statusCode = -1, - message = e.message ?: "Unknown error", - totalItems = "", - itemsPerPage = "" - ), - data = e.message ?: "Unknown error" - ) - ) - ) - } - } -} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt deleted file mode 100644 index 81278099..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.network.api - -import okhttp3.Credentials -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.logging.HttpLoggingInterceptor -import java.util.concurrent.TimeUnit - -class ApiHttpClient private constructor( - val okHttpClient: OkHttpClient, - val credentials: ApiCredentials -) { - companion object { - private const val CONNECT_TIMEOUT_SECONDS = 90L - private const val READ_TIMEOUT_SECONDS = 90L - private const val WRITE_TIMEOUT_SECONDS = 90L - - private const val HTTP_PREFIX = "http://" - private const val HTTPS_PREFIX = "https://" - - private const val HEADER_ACCEPT_NAME = "Accept" - private const val HEADER_ACCEPT_VALUE = "application/json" - - private const val HEADER_AUTHORIZATION_NAME = "Authorization" - - private const val HEADER_OCS_NAME = "OCS-APIRequest" - private const val HEADER_OCS_VALUE = "true" - - fun create( - credentials: ApiCredentials, - enableLogging: Boolean = false - ): ApiHttpClient { - val authInterceptor = AuthInterceptor(credentials) - - val loggingInterceptor = HttpLoggingInterceptor().apply { - level = if (enableLogging) { - HttpLoggingInterceptor.Level.BODY - } else { - HttpLoggingInterceptor.Level.NONE - } - } - - val okHttpClient = OkHttpClient.Builder() - .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .addInterceptor(authInterceptor) - .addInterceptor(loggingInterceptor) - .build() - - return ApiHttpClient(okHttpClient, credentials) - } - } - - fun buildRequest(url: String, method: ApiMethod, body: okhttp3.RequestBody? = null): Request = - Request.Builder() - .url(url) - .header(HEADER_ACCEPT_NAME, HEADER_ACCEPT_VALUE) - .method(method.type, body) - .build() - - private class AuthInterceptor(private val credentials: ApiCredentials) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val basicCredentials = Credentials.basic(credentials.username, credentials.token) - - val request = chain.request() - .newBuilder() - .header(HEADER_AUTHORIZATION_NAME, basicCredentials) - .header(HEADER_OCS_NAME, HEADER_OCS_VALUE) - .url(buildUrl(chain.request().url.toString(), credentials.baseURL)) - .build() - - return chain.proceed(request) - } - - private fun buildUrl(requestUrl: String, baseUrl: String): String { - return if (requestUrl.startsWith(HTTP_PREFIX) || requestUrl.startsWith(HTTPS_PREFIX)) { - requestUrl - } else { - // TODO - remove hardcoded value? - "${baseUrl.trimEnd('/')}/${requestUrl.trimStart('/')}" - } - } - } -} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/AuthInterceptor.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/AuthInterceptor.kt new file mode 100644 index 00000000..07c7b485 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/AuthInterceptor.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network.auth + +import okhttp3.Credentials +import okhttp3.Interceptor +import okhttp3.Response + +class AuthInterceptor(private val credentials: ServerCredentials) : Interceptor { + + private companion object { + private const val HTTP_PREFIX = "http://" + private const val HTTPS_PREFIX = "https://" + private const val DELIMITER = '/' + + private const val HEADER_AUTHORIZATION = "Authorization" + private const val HEADER_OCS_REQUEST = "OCS-APIRequest" + private const val HEADER_OCS_REQUEST_VALUE = "true" + } + + override fun intercept(chain: Interceptor.Chain): Response { + val basicCredentials = Credentials.basic(credentials.username, credentials.token) + + val request = chain.request() + .newBuilder() + .header(HEADER_AUTHORIZATION, basicCredentials) + .header(HEADER_OCS_REQUEST, HEADER_OCS_REQUEST_VALUE) + .url(resolveUrl(chain.request().url.toString())) + .build() + + return chain.proceed(request) + } + + /** + * Prepends [ServerCredentials.baseURL] to relative URLs. + * Absolute URLs (starting with http/https) are passed through unchanged. + */ + private fun resolveUrl(requestUrl: String): String = + if (requestUrl.startsWith(HTTP_PREFIX) || requestUrl.startsWith(HTTPS_PREFIX)) { + requestUrl + } else { + "${credentials.baseURL.trimEnd(DELIMITER)}/${requestUrl.trimStart(DELIMITER)}" + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiCredentials.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/ServerCredentials.kt similarity index 72% rename from ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiCredentials.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/network/auth/ServerCredentials.kt index f6770090..b299353c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiCredentials.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/ServerCredentials.kt @@ -5,10 +5,10 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.network.api +package com.nextcloud.android.common.ui.network.auth -data class ApiCredentials( +data class ServerCredentials( val baseURL: String, val username: String, val token: String -) +) \ No newline at end of file diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiMethod.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/HttpMethod.kt similarity index 70% rename from ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiMethod.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/network/http/HttpMethod.kt index 45e56478..f5901b8d 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiMethod.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/HttpMethod.kt @@ -5,11 +5,11 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.network.api +package com.nextcloud.android.common.ui.network.http -enum class ApiMethod(val type: String) { +enum class HttpMethod(val type: String) { GET("GET"), POST("POST"), PUT("PUT"), DELETE("DELETE") -} +} \ No newline at end of file diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt new file mode 100644 index 00000000..8996bfb2 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt @@ -0,0 +1,76 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network.http + +import com.nextcloud.android.common.ui.network.auth.AuthInterceptor +import com.nextcloud.android.common.ui.network.auth.ServerCredentials +import com.nextcloud.android.common.ui.network.model.NetworkResult +import com.nextcloud.android.common.ui.network.model.OcsResponse +import com.nextcloud.android.common.ui.network.serialization.OCSSerializer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import okhttp3.logging.HttpLoggingInterceptor +import java.util.concurrent.TimeUnit + +class NextcloudHttpClient private constructor( + val okHttpClient: OkHttpClient, + val credentials: ServerCredentials +) { + companion object { + private const val CONNECT_TIMEOUT_SECONDS = 90L + private const val READ_TIMEOUT_SECONDS = 90L + private const val WRITE_TIMEOUT_SECONDS = 90L + + fun create( + credentials: ServerCredentials, + enableLogging: Boolean = false + ): NextcloudHttpClient { + val loggingInterceptor = HttpLoggingInterceptor().apply { + level = if (enableLogging) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + } + + val okHttpClient = OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .addInterceptor(AuthInterceptor(credentials)) + .addInterceptor(loggingInterceptor) + .build() + + return NextcloudHttpClient(okHttpClient, credentials) + } + } + + private val baseUrl get() = credentials.baseURL.trimEnd('/') + + suspend fun executeRequest( + endpoint: String, + method: HttpMethod, + body: RequestBody? = null, + parse: (String) -> T + ): NetworkResult = withContext(Dispatchers.IO) { + val request = buildOcsRequest("$baseUrl$endpoint", method, body) + try { + val response = okHttpClient.newCall(request).execute() + val responseBody = response.body.string() + if (response.isSuccessful) { + NetworkResult.Success(parse(responseBody)) + } else { + NetworkResult.ServerError(OCSSerializer.json.decodeFromString>(responseBody)) + } + } catch (e: Exception) { + NetworkResult.fromException(e) + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/RequestBuilder.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/RequestBuilder.kt new file mode 100644 index 00000000..7e7560cd --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/RequestBuilder.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network.http + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody + +val JSON_CONTENT_TYPE = "application/json; charset=utf-8".toMediaType() + +private const val HEADER_ACCEPT = "Accept" +private const val HEADER_ACCEPT_VALUE = "application/json" + +fun buildOcsRequest( + url: String, + method: HttpMethod, + body: RequestBody? = null +): Request = Request.Builder() + .url(url) + .header(HEADER_ACCEPT, HEADER_ACCEPT_VALUE) + .method(method.type, body) + .build() diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/model/ApiResult.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/ApiResult.kt deleted file mode 100644 index 9ab9328b..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/model/ApiResult.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.network.model - -sealed class ApiResult { - data class Success(val data: T) : ApiResult() - data class Error(val error: OcsResponse) : ApiResult() -} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt new file mode 100644 index 00000000..932761b6 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network.model + +sealed class NetworkResult { + data class Success(val data: T) : NetworkResult() + + data class ServerError(val response: OcsResponse) : NetworkResult() + + data class NetworkException(val throwable: Throwable) : NetworkResult() + + val isSuccess: Boolean get() = this is Success + + companion object { + fun fromException(e: Throwable): NetworkException = NetworkException(e) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/serialization/OCSSerializer.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/serialization/OCSSerializer.kt new file mode 100644 index 00000000..aae4ab75 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/serialization/OCSSerializer.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network.serialization + +import kotlinx.serialization.json.Json + +object OCSSerializer { + val json = Json { ignoreUnknownKeys = true } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 69eae020..a7dcadeb 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -88,8 +88,8 @@ import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission import com.nextcloud.android.common.ui.share.model.ui.customPermissionFields -import com.nextcloud.android.common.ui.network.api.ApiCredentials -import com.nextcloud.android.common.ui.network.api.ApiHttpClient +import com.nextcloud.android.common.ui.network.auth.ServerCredentials +import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient import com.nextcloud.android.common.ui.share.repository.MockShareRepository import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch @@ -726,9 +726,9 @@ private fun PreviewTheme( } } -fun ComposeView.setupUnifiedShare(colorScheme: ColorScheme, credentials: ApiCredentials) { - val apiHttpClient = ApiHttpClient.create(credentials) - val viewModel = ShareViewModel(repository = ShareRemoteRepository(apiHttpClient)) +fun ComposeView.setupUnifiedShare(colorScheme: ColorScheme, credentials: ServerCredentials) { + val nextcloudHttpClient = NextcloudHttpClient.create(credentials) + val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)) setContent { MaterialTheme( diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 5621326e..0896d0dd 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -10,7 +10,7 @@ package com.nextcloud.android.common.ui.share import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.android.common.ui.R -import com.nextcloud.android.common.ui.network.model.ApiResult +import com.nextcloud.android.common.ui.network.model.NetworkResult import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare import com.nextcloud.android.common.ui.share.repository.ShareRepository import kotlinx.coroutines.Dispatchers @@ -43,11 +43,11 @@ class ShareViewModel( _errorMessageId.value = null when (val result = repository.fetchShares()) { - is ApiResult.Success -> { + is NetworkResult.Success -> { _shares.update { result.data } } - is ApiResult.Error -> { + is NetworkResult.Error -> { _errorMessageId.value = R.string.share_view_fetch_error_message } } @@ -79,7 +79,7 @@ class ShareViewModel( } val result = repository.deleteShare(share.id) - if (result is ApiResult.Error) { + if (result is NetworkResult.Error) { _errorMessageId.update { R.string.share_view_delete_error_message } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt index 053d951c..b2f416b6 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -7,7 +7,7 @@ package com.nextcloud.android.common.ui.share.repository -import com.nextcloud.android.common.ui.network.model.ApiResult +import com.nextcloud.android.common.ui.network.model.NetworkResult import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse import com.nextcloud.android.common.ui.share.model.api.owner.Owner @@ -22,7 +22,7 @@ class MockShareRepository : ShareRepository { query: String, limit: Int, offset: Int - ): ApiResult> { + ): NetworkResult> { val mock = listOf( ShareRecipients( @@ -53,12 +53,12 @@ class MockShareRepository : ShareRepository { ) ) - return ApiResult.Success(mock) + return NetworkResult.Success(mock) } override suspend fun createShare( request: CreateShareRequest - ): ApiResult { + ): NetworkResult { val response = ShareDataResponse( sources = request.data.sources, @@ -72,10 +72,10 @@ class MockShareRepository : ShareRepository { ) ) - return ApiResult.Success(response) + return NetworkResult.Success(response) } - override suspend fun fetchShare(id: String): ApiResult { + override suspend fun fetchShare(id: String): NetworkResult { val mock = ShareDataResponse( sources = emptyList(), @@ -95,13 +95,13 @@ class MockShareRepository : ShareRepository { ) ) - return ApiResult.Success(mock) + return NetworkResult.Success(mock) } override suspend fun updateShare( id: String, request: UpdateShareRequest - ): ApiResult { + ): NetworkResult { val updated = ShareDataResponse( sources = request.data.sources, @@ -112,18 +112,18 @@ class MockShareRepository : ShareRepository { owner = request.data.owner ) - return ApiResult.Success(updated) + return NetworkResult.Success(updated) } - override suspend fun deleteShare(id: String): ApiResult { - return ApiResult.Success(Unit) + override suspend fun deleteShare(id: String): NetworkResult { + return NetworkResult.Success(Unit) } override suspend fun fetchShares( sourceType: String?, lastShareId: String?, limit: Int - ): ApiResult> { + ): NetworkResult> { val data = listOf( UnifiedShare( id = "1", @@ -276,6 +276,6 @@ class MockShareRepository : ShareRepository { ) ) - return ApiResult.Success(data) + return NetworkResult.Success(data) } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index 0a192145..3c7ca0e2 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -7,217 +7,95 @@ package com.nextcloud.android.common.ui.share.repository -import com.nextcloud.android.common.ui.network.api.ApiHttpClient -import com.nextcloud.android.common.ui.network.api.ApiMethod -import com.nextcloud.android.common.ui.network.model.ApiResult -import com.nextcloud.android.common.ui.network.model.Meta -import com.nextcloud.android.common.ui.network.model.Ocs +import com.nextcloud.android.common.ui.network.http.HttpMethod +import com.nextcloud.android.common.ui.network.http.JSON_CONTENT_TYPE +import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient +import com.nextcloud.android.common.ui.network.model.NetworkResult import com.nextcloud.android.common.ui.network.model.OcsResponse +import com.nextcloud.android.common.ui.network.serialization.OCSSerializer import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse import com.nextcloud.android.common.ui.share.model.api.create.toUnifiedShare import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients import com.nextcloud.android.common.ui.share.model.api.update.UpdateShareRequest import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody -class ShareRemoteRepository(private val client: ApiHttpClient) : ShareRepository { +class ShareRemoteRepository( + private val client: NextcloudHttpClient, + private val json: kotlinx.serialization.json.Json = OCSSerializer.json +) : ShareRepository { - companion object { + private companion object { + // Trailing slash intentional, share ID is appended directly: "$SHARE_ENDPOINT$id" private const val SHARE_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/share/" + private const val SHARES_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/shares" + private const val RECIPIENTS_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/recipients" } - private val json = Json { ignoreUnknownKeys = true } - - // TODO - move from here? - private val jsonMediaType = "application/json; charset=utf-8".toMediaType() - - // TODO - trim end? - private val baseUrl get() = client.credentials.baseURL.trimEnd('/') - - // TODO - Move from here - private fun errorResult(e: Exception): ApiResult.Error = - ApiResult.Error( - OcsResponse( - Ocs( - meta = Meta( - status = "error", - statusCode = -1, - message = e.message ?: "Unknown error" - ), - data = e.message ?: "Unknown error" - ) - ) - ) - - /** - * Searches for recipients - */ override suspend fun fetchRecipients( recipientType: String, query: String, limit: Int, offset: Int - ): ApiResult> = withContext(Dispatchers.IO) { - // TODO: - CHECK URL GET - // /ocs/v2.php/apps/sharing/api/v1/recipients - val url = "$baseUrl/ocs/v2.php/apps/sharing/api/v1/recipients" + - "?recipientType=$recipientType&query=$query&limit=$limit&offset=$offset" - - val request = client.buildRequest(url, ApiMethod.GET) - - try { - val response = client.okHttpClient.newCall(request).execute() - val body = response.body.string() - if (response.isSuccessful) { - val parsed = json.decodeFromString>>(body) - ApiResult.Success(parsed.ocs.data) - } else { - ApiResult.Error(json.decodeFromString>(body)) - } - } catch (e: Exception) { - errorResult(e) + ): NetworkResult> = + client.executeRequest( + endpoint = "$RECIPIENTS_ENDPOINT?recipientType=$recipientType&query=$query&limit=$limit&offset=$offset", + method = HttpMethod.GET + ) { body -> + json.decodeFromString>>(body).ocs.data } - } - override suspend fun createShare(request: CreateShareRequest): ApiResult = - withContext(Dispatchers.IO) { - /* - POST - /ocs/v2.php/apps/sharing/api/v1/share - */ - val url = "$baseUrl$SHARE_ENDPOINT" - val body = json.encodeToString(request).toRequestBody(jsonMediaType) - - val httpRequest = client.buildRequest(url, ApiMethod.POST, body) - - try { - val response = client.okHttpClient.newCall(httpRequest).execute() - val responseBody = response.body.string() - if (response.isSuccessful) { - val parsed = json.decodeFromString>(responseBody) - ApiResult.Success(parsed.ocs.data) - } else { - ApiResult.Error(json.decodeFromString>(responseBody)) - } - } catch (e: Exception) { - errorResult(e) - } + override suspend fun createShare(request: CreateShareRequest): NetworkResult = + client.executeRequest( + endpoint = SHARE_ENDPOINT, + method = HttpMethod.POST, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { body -> + json.decodeFromString>(body).ocs.data } - override suspend fun fetchShare(id: String): ApiResult = - withContext(Dispatchers.IO) { - /* - POST - /ocs/v2.php/apps/sharing/api/v1/share/{id} - */ - val url = "$baseUrl$SHARE_ENDPOINT$id" - val request = client.buildRequest(url, ApiMethod.GET) - - try { - val response = client.okHttpClient.newCall(request).execute() - val body = response.body.string() - if (response.isSuccessful) { - val parsed = json.decodeFromString>(body) - ApiResult.Success(parsed.ocs.data) - } else { - ApiResult.Error(json.decodeFromString>(body)) - } - } catch (e: Exception) { - errorResult(e) - } + override suspend fun fetchShare(id: String): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT$id", + method = HttpMethod.GET + ) { body -> + json.decodeFromString>(body).ocs.data } - override suspend fun updateShare(id: String, request: UpdateShareRequest): ApiResult = - withContext(Dispatchers.IO) { - /* - PUT - /ocs/v2.php/apps/sharing/api/v1/share/{id} - */ - val url = "$baseUrl$SHARE_ENDPOINT$id" - val body = json.encodeToString(request).toRequestBody(jsonMediaType) - - val httpRequest = client.buildRequest(url, ApiMethod.PUT, body) - - try { - val response = client.okHttpClient.newCall(httpRequest).execute() - val responseBody = response.body.string() - if (response.isSuccessful) { - val parsed = json.decodeFromString>(responseBody) - ApiResult.Success(parsed.ocs.data) - } else { - ApiResult.Error(json.decodeFromString>(responseBody)) - } - } catch (e: Exception) { - errorResult(e) - } + override suspend fun updateShare(id: String, request: UpdateShareRequest): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT$id", + method = HttpMethod.PUT, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { body -> + json.decodeFromString>(body).ocs.data } - override suspend fun deleteShare(id: String): ApiResult = - withContext(Dispatchers.IO) { - /* - DELETE - /ocs/v2.php/apps/sharing/api/v1/share/{id} - */ - val url = "$baseUrl$SHARE_ENDPOINT$id" - val request = client.buildRequest(url, ApiMethod.DELETE) - - try { - val response = client.okHttpClient.newCall(request).execute() - if (response.isSuccessful) { - ApiResult.Success(Unit) - } else { - val body = response.body.string() - ApiResult.Error(json.decodeFromString>(body)) - } - } catch (e: Exception) { - errorResult(e) - } - } + override suspend fun deleteShare(id: String): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT$id", + method = HttpMethod.DELETE + ) { } - /** - * @param sourceType - * Optional filter to return only shares matching a specific source type. - * When null, shares of all source types are returned. - * - * @param lastShareId - * Pagination cursor representing the last known share ID. - * Only shares with an ID greater than this value will be returned. - * When null, results start from the first available share. - */ override suspend fun fetchShares( sourceType: String?, lastShareId: String?, limit: Int - ): ApiResult> = withContext(Dispatchers.IO) { - /* - GET - /ocs/v2.php/apps/sharing/api/v1/shares - */ + ): NetworkResult> { val queryParams = buildString { append("?limit=$limit") sourceType?.let { append("&sourceType=$it") } lastShareId?.let { append("&lastShareId=$it") } } - val url = "$baseUrl$SHARES_ENDPOINT$queryParams" - val request = client.buildRequest(url, ApiMethod.GET) - - try { - val response = client.okHttpClient.newCall(request).execute() - val body = response.body.string() - if (response.isSuccessful) { - val parsed = json.decodeFromString>>(body) - ApiResult.Success(parsed.ocs.data.map { it.toUnifiedShare() }) - } else { - ApiResult.Error(json.decodeFromString>(body)) - } - } catch (e: Exception) { - errorResult(e) + return client.executeRequest( + endpoint = "$SHARES_ENDPOINT$queryParams", + method = HttpMethod.GET + ) { body -> + json.decodeFromString>>(body) + .ocs.data + .map { it.toUnifiedShare() } } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt index 3267d9db..db90524e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt @@ -7,7 +7,7 @@ package com.nextcloud.android.common.ui.share.repository -import com.nextcloud.android.common.ui.network.model.ApiResult +import com.nextcloud.android.common.ui.network.model.NetworkResult import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients @@ -20,19 +20,19 @@ interface ShareRepository { query: String, limit: Int = 10, offset: Int = 0 - ): ApiResult> + ): NetworkResult> - suspend fun createShare(request: CreateShareRequest): ApiResult + suspend fun createShare(request: CreateShareRequest): NetworkResult - suspend fun fetchShare(id: String): ApiResult + suspend fun fetchShare(id: String): NetworkResult - suspend fun updateShare(id: String, request: UpdateShareRequest): ApiResult + suspend fun updateShare(id: String, request: UpdateShareRequest): NetworkResult - suspend fun deleteShare(id: String): ApiResult + suspend fun deleteShare(id: String): NetworkResult suspend fun fetchShares( sourceType: String? = null, lastShareId: String? = null, limit: Int = 100 - ): ApiResult> + ): NetworkResult> } From 842b6a0bbe8f143f4c07f754aa34f39458c8a988 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 6 May 2026 08:56:45 +0200 Subject: [PATCH 15/95] wip Signed-off-by: alperozturk96 --- .../android/common/sample/MainViewModel.kt | 8 +++-- .../android/common/ui/share/ShareViewModel.kt | 31 +++++++++---------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt index 0738ab44..4744a557 100644 --- a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt +++ b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt @@ -36,9 +36,13 @@ class MainViewModel : ViewModel() { "✅ Success (${result.data.size} statuses):\n" + result.data.joinToString("\n") { "${it.icon} ${it.message}" } - is NetworkResult.Error -> + is NetworkResult.ServerError -> apiTestResult.value = - "❌ Error ${result.error.ocs.meta.statusCode}: ${result.error.ocs.meta.message}" + "❌ Error ${result.response.ocs.meta.statusCode}: ${result.response.ocs.meta.message}" + + is NetworkResult.NetworkException -> + apiTestResult.value = + "❌ Exception: ${result.throwable.message}" } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 0896d0dd..a7d65469 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -42,12 +42,14 @@ class ShareViewModel( _loading.value = true _errorMessageId.value = null - when (val result = repository.fetchShares()) { - is NetworkResult.Success -> { - _shares.update { result.data } - } - - is NetworkResult.Error -> { + when (val result = repository.fetchShares( + sourceType = null, + lastShareId = null, + limit = 50 + )) { + is NetworkResult.Success -> _shares.update { result.data } + is NetworkResult.ServerError, + is NetworkResult.NetworkException -> { _errorMessageId.value = R.string.share_view_fetch_error_message } } @@ -70,20 +72,17 @@ class ShareViewModel( fun delete(share: UnifiedShare) { viewModelScope.launch(Dispatchers.IO) { - val id = share.id - if (id == null) { - _errorMessageId.update { - R.string.share_view_delete_error_id_not_found_message - } + val id = share.id ?: run { + _errorMessageId.update { R.string.share_view_delete_error_id_not_found_message } return@launch } - val result = repository.deleteShare(share.id) - if (result is NetworkResult.Error) { - _errorMessageId.update { - R.string.share_view_delete_error_message + when (repository.deleteShare(id)) { + is NetworkResult.Success -> _shares.update { current -> current.filterNot { it.id == id } } + is NetworkResult.ServerError, + is NetworkResult.NetworkException -> { + _errorMessageId.update { R.string.share_view_delete_error_message } } - return@launch } } } From dfb94110947290908b212c130aca6b65cbc34715 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 12:18:47 +0300 Subject: [PATCH 16/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 3 +- .../android/common/ui/share/ShareViewModel.kt | 224 +++++-- .../model/api/create/CreateShareRequest.kt | 23 - .../model/api/create/ShareDataResponse.kt | 50 -- .../common/ui/share/model/api/icon/Icon.kt | 17 + .../common/ui/share/model/api/owner/Owner.kt | 8 +- .../share/model/api/permission/Permission.kt | 24 + .../ui/share/model/api/property/Property.kt | 123 ++++ .../{ShareRecipients.kt => Recipient.kt} | 20 +- .../share/model/api/request/ShareRequests.kt | 58 ++ .../common/ui/share/model/api/share/Share.kt | 37 ++ .../{user/ShareUser.kt => source/Source.kt} | 8 +- .../ui/share/model/api/state/ShareState.kt | 23 + .../model/api/update/UpdateShareRequest.kt | 32 - .../common/ui/share/model/ui/UnifiedShares.kt | 7 +- .../share/repository/MockShareRepository.kt | 601 +++++++++++------- .../share/repository/ShareRemoteRepository.kt | 163 +++-- .../ui/share/repository/ShareRepository.kt | 78 ++- ui/src/main/res/values/strings.xml | 10 +- 19 files changed, 1061 insertions(+), 448 deletions(-) delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/CreateShareRequest.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/icon/Icon.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt rename ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/{ShareRecipients.kt => Recipient.kt} (61%) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt rename ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/{user/ShareUser.kt => source/Source.kt} (72%) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/state/ShareState.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/update/UpdateShareRequest.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index a7dcadeb..1b07956c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -94,7 +94,6 @@ import com.nextcloud.android.common.ui.share.repository.MockShareRepository import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch - @Composable private fun ShareView(viewModel: ShareViewModel) { val errorMessageId by viewModel.errorMessageId.collectAsState() @@ -138,7 +137,7 @@ private fun ShareView(viewModel: ShareViewModel) { UnifiedSharesListItem(share, type, onSelectShare = { share -> bottomSheetState = ShareBottomSheetState.Edit(share) }, onDeleteShare = { - viewModel.delete(share) + viewModel.deleteShare(share.id) }, onSendEmail = { // TODO: }) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index a7d65469..ef712815 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -11,7 +11,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.network.model.NetworkResult -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare +import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest +import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest +import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest +import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.repository.ShareRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -23,8 +30,11 @@ class ShareViewModel( private val repository: ShareRepository ) : ViewModel() { - private val _shares = MutableStateFlow>(emptyList()) - val shares: StateFlow> = _shares + private val _shares = MutableStateFlow>(emptyList()) + val shares: StateFlow> = _shares + + private val _activeShare = MutableStateFlow(null) + val activeShare: StateFlow = _activeShare private val _loading = MutableStateFlow(false) val loading: StateFlow = _loading @@ -36,61 +46,201 @@ class ShareViewModel( loadShares() } - // region private methods - private fun loadShares() { + // region shares list + fun loadShares( + sourceClass: String? = null, + lastShareID: String? = null, + limit: Int = 50 + ) { + launchWithLoading { + handleResult( + result = repository.fetchShares( + sourceClass = sourceClass, + lastShareID = lastShareID, + limit = limit + ), + errorId = R.string.share_view_fetch_error_message + ) { _shares.update { it } } + } + } + + fun fetchShare(id: String, request: GetShareRequest = GetShareRequest()) { + launchWithLoading { + handleResult( + result = repository.fetchShare(id, request), + errorId = R.string.share_view_fetch_error_message + ) { share -> + _activeShare.update { share } + replaceInList(share) + } + } + } + // endregion + + // region create + + /** + * Creates an empty draft [Share] on the server and sets it as the [activeShare]. + * Then sources can be added later. + * + */ + fun createShare(onCreated: (Share) -> Unit = {}) { + launchWithLoading { + handleResult( + result = repository.createShare(), + errorId = R.string.share_view_create_error_message + ) { draft -> + _activeShare.update { draft } + _shares.update { current -> listOf(draft) + current } + onCreated(draft) + } + } + } + // endregion + + // region state + fun updateState(id: String, shareState: ShareState) { viewModelScope.launch(Dispatchers.IO) { - _loading.value = true - _errorMessageId.value = null - - when (val result = repository.fetchShares( - sourceType = null, - lastShareId = null, - limit = 50 - )) { - is NetworkResult.Success -> _shares.update { result.data } - is NetworkResult.ServerError, - is NetworkResult.NetworkException -> { - _errorMessageId.value = R.string.share_view_fetch_error_message - } + handleResult( + result = repository.updateShareState(id, UpdateShareStateRequest(shareState)), + errorId = R.string.share_view_update_error_message + ) { updated -> + _activeShare.update { updated } + replaceInList(updated) } + } + } + // endregion - _loading.value = false + // region sources + fun addSource(id: String, clazz: String, value: String) { + viewModelScope.launch(Dispatchers.IO) { + handleResult( + result = repository.addShareSource(id, AddSourceRequest(clazz, value)), + errorId = R.string.share_view_update_error_message + ) { updated -> + _activeShare.update { updated } + replaceInList(updated) + } + } + } + + fun removeSource(id: String, clazz: String, value: String) { + viewModelScope.launch(Dispatchers.IO) { + handleResult( + result = repository.removeShareSource(id, clazz, value), + errorId = R.string.share_view_update_error_message + ) { updated -> + _activeShare.update { updated } + replaceInList(updated) + } } } // endregion - // region public methods - fun create(share: UnifiedShare) { + // region recipients + fun addRecipient(id: String, clazz: String, value: String, instance: String? = null) { + viewModelScope.launch(Dispatchers.IO) { + handleResult( + result = repository.addShareRecipient(id, AddRecipientRequest(clazz, value, instance)), + errorId = R.string.share_view_update_error_message + ) { updated -> + _activeShare.update { updated } + replaceInList(updated) + } + } + } + + fun removeRecipient(id: String, clazz: String, value: String, instance: String? = null) { viewModelScope.launch(Dispatchers.IO) { - /* - val request = CreateShareRequest() - repository.createShare() - */ + handleResult( + result = repository.removeShareRecipient(id, clazz, value, instance), + errorId = R.string.share_view_update_error_message + ) { updated -> + _activeShare.update { updated } + replaceInList(updated) + } + } + } + // endregion + // region properties + fun updateProperty(id: String, clazz: String, value: String?) { + viewModelScope.launch(Dispatchers.IO) { + handleResult( + result = repository.updateShareProperty(id, UpdateSharePropertyRequest(clazz, value)), + errorId = R.string.share_view_update_error_message + ) { updated -> + _activeShare.update { updated } + replaceInList(updated) + } } } + // endregion - fun delete(share: UnifiedShare) { + // region permissions + fun updatePermission(id: String, clazz: String, enabled: Boolean) { viewModelScope.launch(Dispatchers.IO) { - val id = share.id ?: run { - _errorMessageId.update { R.string.share_view_delete_error_id_not_found_message } - return@launch + handleResult( + result = repository.updateSharePermission(id, UpdateSharePermissionRequest(clazz, enabled)), + errorId = R.string.share_view_update_error_message + ) { updated -> + _activeShare.update { updated } + replaceInList(updated) } + } + } + // endregion - when (repository.deleteShare(id)) { - is NetworkResult.Success -> _shares.update { current -> current.filterNot { it.id == id } } - is NetworkResult.ServerError, - is NetworkResult.NetworkException -> { - _errorMessageId.update { R.string.share_view_delete_error_message } - } + // region delete + fun deleteShare(id: String) { + viewModelScope.launch(Dispatchers.IO) { + handleResult( + result = repository.deleteShare(id), + errorId = R.string.share_view_delete_error_message + ) { + _shares.update { current -> current.filterNot { it.id == id } } + if (_activeShare.value?.id == id) _activeShare.update { null } } } } + // endregion + // region ui helpers fun updateErrorMessage(value: Int?) { - _errorMessageId.update { - value + _errorMessageId.update { value } + } + + fun clearActiveShare() { + _activeShare.update { null } + } + // endregion + + // region private + private fun replaceInList(updated: Share) { + _shares.update { current -> current.map { if (it.id == updated.id) updated else it } } + } + + private fun handleResult( + result: NetworkResult, + errorId: Int, + onSuccess: (T) -> Unit + ) { + when (result) { + is NetworkResult.Success -> onSuccess(result.data) + is NetworkResult.ServerError, + is NetworkResult.NetworkException -> _errorMessageId.update { errorId } + } + } + + private fun launchWithLoading(block: suspend () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + _loading.update { true } + _errorMessageId.update { null } + block() + _loading.update { false } } } + // endregion } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/CreateShareRequest.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/CreateShareRequest.kt deleted file mode 100644 index 54fa9da7..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/CreateShareRequest.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.model.api.create - -import com.nextcloud.android.common.ui.share.model.api.user.ShareUser -import kotlinx.serialization.Serializable - -@Serializable -data class CreateShareRequest( - val data: ShareDataRequest -) - -@Serializable -data class ShareDataRequest( - val sources: List, - val recipients: List, - val properties: Map>> -) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt deleted file mode 100644 index 1d2ec2f2..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.model.api.create - -import com.nextcloud.android.common.ui.share.model.api.owner.Owner -import com.nextcloud.android.common.ui.share.model.api.user.ShareUser -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory -import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareType -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class ShareDataResponse( - val sources: List, - val recipients: List, - val properties: Map>>, - - val id: String, - - @SerialName("last_updated") - val lastUpdated: Long, - - val owner: Owner -) - -fun ShareDataResponse.toUnifiedShare(): UnifiedShare { - val primarySource = sources.firstOrNull() - return UnifiedShare( - id = id, - sources = sources, - recipients = recipients, - properties = properties, - lastUpdated = lastUpdated, - owner = owner, - type = UnifiedShareType.toUnifiedShareType(primarySource?.type), - category = UnifiedShareCategory.Invited, // TODO map from properties - permission = UnifiedSharePermission.CanView, // TODO map from properties - label = primarySource?.displayName ?: "Unknown", - note = "", // TODO map from properties - password = "", // TODO map from properties - limit = null // TODO map from properties - ) -} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/icon/Icon.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/icon/Icon.kt new file mode 100644 index 00000000..4343042a --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/icon/Icon.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.icon + +import kotlinx.serialization.Serializable + +@Serializable +data class Icon( + val svg: String? = null, + val light: String? = null, + val dark: String? = null +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt index cb75c5d7..d5646004 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt @@ -9,13 +9,17 @@ package com.nextcloud.android.common.ui.share.model.api.owner import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable - +import com.nextcloud.android.common.ui.share.model.api.icon.Icon @Serializable data class Owner( @SerialName("user_id") val userId: String, + val instance: String? = null, + @SerialName("display_name") - val displayName: String + val displayName: String, + + val icon: Icon ) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt new file mode 100644 index 00000000..eae0441a --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.permission + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Permission( + @SerialName("class") + val clazz: String, + + @SerialName("display_name") + val displayName: String, + + val category: String?, + + val enabled: Boolean +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt new file mode 100644 index 00000000..1520f771 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt @@ -0,0 +1,123 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.property + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonClassDiscriminator + +@OptIn(ExperimentalSerializationApi::class) +@JsonClassDiscriminator("type") +@Serializable +sealed class Property + +@Serializable +@SerialName("boolean") +data class PropertyBoolean( + @SerialName("class") + val clazz: String, + + @SerialName("display_name") + val displayName: String, + + val hint: String? = null, + + val priority: Int, + + val required: Boolean, + + val value: String? = null +) : Property() + +@Serializable +@SerialName("date") +data class PropertyDate( + @SerialName("class") + val clazz: String, + + @SerialName("display_name") + val displayName: String, + + val hint: String? = null, + + val priority: Int, + + val required: Boolean, + + val value: String? = null, + + @SerialName("min_date") + val minDate: String? = null, + + @SerialName("max_date") + val maxDate: String? = null +) : Property() + +@Serializable +@SerialName("enum") +data class PropertyEnum( + @SerialName("class") + val clazz: String, + + @SerialName("display_name") + val displayName: String, + + val hint: String? = null, + + val priority: Int, + + val required: Boolean, + + val value: String? = null, + + @SerialName("valid_values") + val validValues: List +) : Property() + +@Serializable +@SerialName("password") +data class PropertyPassword( + @SerialName("class") + val clazz: String, + + @SerialName("display_name") + val displayName: String, + + val hint: String? = null, + + val priority: Int, + + val required: Boolean, + + val value: String? = null +) : Property() + +@Serializable +@SerialName("string") +data class PropertyString( + @SerialName("class") + val clazz: String, + + @SerialName("display_name") + val displayName: String, + + val hint: String? = null, + + val priority: Int, + + val required: Boolean, + + val value: String? = null, + + @SerialName("min_length") + val minLength: Int? = null, + + @SerialName("max_length") + val maxLength: Int? = null +) : Property() diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/ShareRecipients.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt similarity index 61% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/ShareRecipients.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt index a9bba350..36d876b1 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/ShareRecipients.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt @@ -7,23 +7,21 @@ package com.nextcloud.android.common.ui.share.model.api.recipients +import com.nextcloud.android.common.ui.share.model.api.icon.Icon import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class ShareRecipients( - val type: String, +data class Recipient( + @SerialName("class") + val clazz: String, + val value: String, + val instance: String? = null, + @SerialName("display_name") val displayName: String, - @SerialName("display_name_unique") - val displayNameUnique: String, - - @SerialName("icon_url_light") - val iconUrlLight: String, - - @SerialName("icon_url_dark") - val iconUrlDark: String -) \ No newline at end of file + val icon: Icon? = null +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt new file mode 100644 index 00000000..b0502ec6 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt @@ -0,0 +1,58 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.request + +import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class GetShareRequest( + val secret: String? = null, + val arguments: Map = emptyMap() +) + +@Serializable +data class UpdateShareStateRequest( + val shareState: ShareState +) + +@Serializable +data class AddSourceRequest( + @SerialName("class") + val clazz: String, + + val value: String +) + +@Serializable +data class AddRecipientRequest( + @SerialName("class") + val clazz: String, + + val value: String, + + val instance: String? = null +) + +@Serializable +data class UpdateSharePropertyRequest( + @SerialName("class") + val clazz: String, + + val value: String? = null +) + +@Serializable +data class UpdateSharePermissionRequest( + @SerialName("class") + val clazz: String, + + val enabled: Boolean +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt new file mode 100644 index 00000000..8b67d24d --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.share + +import com.nextcloud.android.common.ui.share.model.api.owner.Owner +import com.nextcloud.android.common.ui.share.model.api.permission.Permission +import com.nextcloud.android.common.ui.share.model.api.property.Property +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient +import com.nextcloud.android.common.ui.share.model.api.source.Source +import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Share( + val id: String, + + val owner: Owner, + + @SerialName("last_updated") + val lastUpdated: Long, + + val shareState: ShareState, + + val sources: List, + + val recipients: List, + + val properties: List, + + val permissions: List +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/ShareUser.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/source/Source.kt similarity index 72% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/ShareUser.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/source/Source.kt index c0709a89..b5dfa16f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/ShareUser.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/source/Source.kt @@ -5,14 +5,16 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.share.model.api.user +package com.nextcloud.android.common.ui.share.model.api.source import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class ShareUser( - val type: String, +data class Source( + @SerialName("class") + val clazz: String, + val value: String, @SerialName("display_name") diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/state/ShareState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/state/ShareState.kt new file mode 100644 index 00000000..2846dc06 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/state/ShareState.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.state + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class ShareState { + @SerialName("active") + ACTIVE, + + @SerialName("draft") + DRAFT, + + @SerialName("deleted") + DELETED +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/update/UpdateShareRequest.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/update/UpdateShareRequest.kt deleted file mode 100644 index 926177af..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/update/UpdateShareRequest.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.model.api.update - -import com.nextcloud.android.common.ui.share.model.api.owner.Owner -import com.nextcloud.android.common.ui.share.model.api.user.ShareUser -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class UpdateShareRequest( - val data: UpdateShareData -) - -@Serializable -data class UpdateShareData( - val sources: List, - val recipients: List, - val properties: Map>>, - - val id: String, - - @SerialName("last_updated") - val lastUpdated: Long, - - val owner: Owner -) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt index 5690cb5f..0e24f7e5 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt @@ -8,12 +8,13 @@ package com.nextcloud.android.common.ui.share.model.ui import com.nextcloud.android.common.ui.share.model.api.owner.Owner -import com.nextcloud.android.common.ui.share.model.api.user.ShareUser +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient +import com.nextcloud.android.common.ui.share.model.api.source.Source data class UnifiedShare( val id: String?, - val sources: List, - val recipients: List, + val sources: List, + val recipients: List, val properties: Map>>, val lastUpdated: Long, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt index b2f416b6..8f7fcf94 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -8,274 +8,425 @@ package com.nextcloud.android.common.ui.share.repository import com.nextcloud.android.common.ui.network.model.NetworkResult -import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest -import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse +import com.nextcloud.android.common.ui.share.model.api.icon.Icon import com.nextcloud.android.common.ui.share.model.api.owner.Owner -import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients -import com.nextcloud.android.common.ui.share.model.api.update.UpdateShareRequest -import com.nextcloud.android.common.ui.share.model.api.user.ShareUser -import com.nextcloud.android.common.ui.share.model.ui.* +import com.nextcloud.android.common.ui.share.model.api.permission.Permission +import com.nextcloud.android.common.ui.share.model.api.property.PropertyBoolean +import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate +import com.nextcloud.android.common.ui.share.model.api.property.PropertyPassword +import com.nextcloud.android.common.ui.share.model.api.property.PropertyString +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient +import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest +import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest +import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest +import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.api.source.Source +import com.nextcloud.android.common.ui.share.model.api.state.ShareState class MockShareRepository : ShareRepository { + + private val mockOwner = Owner( + userId = "alice", + displayName = "Alice Johnson", + icon = Icon( + light = "https://mock/icons/user_light.png", + dark = "https://mock/icons/user_dark.png" + ) + ) + + private val mockPermissions = listOf( + Permission( + clazz = "read", + displayName = "Read", + category = "access", + enabled = true + ), + Permission( + clazz = "update", + displayName = "Update", + category = "access", + enabled = false + ), + Permission( + clazz = "delete", + displayName = "Delete", + category = "access", + enabled = false + ) + ) + + private val mockProperties = listOf( + PropertyString( + clazz = "note", + displayName = "Note", + priority = 10, + required = false, + value = "Design review – please check latest changes" + ), + PropertyPassword( + clazz = "password", + displayName = "Password", + priority = 20, + required = false, + value = null + ), + PropertyDate( + clazz = "expiration_date", + displayName = "Expiration date", + priority = 30, + required = false, + value = null, + minDate = "2026-01-01", + maxDate = "2027-01-01" + ), + PropertyBoolean( + clazz = "hide_download", + displayName = "Hide download", + priority = 40, + required = false, + value = "false" + ) + ) + + private fun buildShare( + id: String, + sources: List, + recipients: List, + shareState: ShareState = ShareState.ACTIVE, + lastUpdated: Long = System.currentTimeMillis(), + owner: Owner = mockOwner + ) = Share( + id = id, + owner = owner, + lastUpdated = lastUpdated, + shareState = shareState, + sources = sources, + recipients = recipients, + properties = mockProperties, + permissions = mockPermissions + ) + + private val mockShares = mutableListOf( + buildShare( + id = "1", + sources = listOf( + Source(clazz = "file", value = "/Photos/vacation.jpg", displayName = "vacation.jpg") + ), + recipients = listOf( + Recipient( + clazz = "user", + value = "alice@company.com", + displayName = "Alice Johnson", + icon = Icon( + light = "https://mock/icons/user_light.png", + dark = "https://mock/icons/user_dark.png" + ) + ) + ) + ), + buildShare( + id = "2", + sources = listOf( + Source(clazz = "file", value = "/Documents/report.pdf", displayName = "report.pdf") + ), + recipients = listOf( + Recipient( + clazz = "group", + value = "marketing", + displayName = "Marketing Team", + icon = Icon( + light = "https://mock/icons/group_light.png", + dark = "https://mock/icons/group_dark.png" + ) + ) + ), + owner = Owner( + userId = "system", + displayName = "System", + icon = Icon( + light = "https://mock/icons/system_light.png", + dark = "https://mock/icons/system_dark.png" + ) + ) + ), + buildShare( + id = "3", + sources = listOf( + Source(clazz = "link", value = "https://nextcloud.com/s/abc123", displayName = "Public Link") + ), + recipients = emptyList(), + lastUpdated = 1710000000L, + owner = Owner( + userId = "system", + displayName = "System", + icon = Icon( + light = "https://mock/icons/system_light.png", + dark = "https://mock/icons/system_dark.png" + ) + ) + ), + buildShare( + id = "4", + sources = listOf( + Source(clazz = "file", value = "/Projects/brief.docx", displayName = "brief.docx") + ), + recipients = listOf( + Recipient( + clazz = "mail", + value = "john@external.com", + displayName = "John External", + icon = Icon( + light = "https://mock/icons/external_light.png", + dark = "https://mock/icons/external_dark.png" + ) + ) + ), + owner = Owner( + userId = "john", + displayName = "John External", + icon = Icon( + light = "https://mock/icons/user_light.png", + dark = "https://mock/icons/user_dark.png" + ) + ) + ), + buildShare( + id = "5", + sources = listOf( + Source(clazz = "file", value = "/Shared/assets.zip", displayName = "assets.zip") + ), + recipients = listOf( + Recipient( + clazz = "federated", + value = "partner@nextcloud.org", + instance = "nextcloud.org", + displayName = "Partner Cloud", + icon = Icon( + light = "https://mock/icons/federated_light.png", + dark = "https://mock/icons/federated_dark.png" + ) + ) + ), + owner = Owner( + userId = "partner", + displayName = "Partner Cloud", + icon = Icon( + light = "https://mock/icons/user_light.png", + dark = "https://mock/icons/user_dark.png" + ) + ) + ) + ) + override suspend fun fetchRecipients( - recipientType: String, + recipientTypeClass: String?, query: String, limit: Int, offset: Int - ): NetworkResult> { - - val mock = listOf( - ShareRecipients( - type = recipientType, + ): NetworkResult> { + val all = listOf( + Recipient( + clazz = "user", value = "alice@company.com", displayName = "Alice Johnson", - displayNameUnique = "Alice Johnson (Company)", - iconUrlLight = "https://mock/icons/user_light.png", - iconUrlDark = "https://mock/icons/user_dark.png" + icon = Icon( + light = "https://mock/icons/user_light.png", + dark = "https://mock/icons/user_dark.png" + ) ), - - ShareRecipients( - type = recipientType, + Recipient( + clazz = "group", value = "marketing", displayName = "Marketing Team", - displayNameUnique = "Marketing Team (Group)", - iconUrlLight = "https://mock/icons/group_light.png", - iconUrlDark = "https://mock/icons/group_dark.png" + icon = Icon( + light = "https://mock/icons/group_light.png", + dark = "https://mock/icons/group_dark.png" + ) ), - - ShareRecipients( - type = recipientType, + Recipient( + clazz = "mail", value = "john@external.com", displayName = "John External", - displayNameUnique = "John External (External)", - iconUrlLight = "https://mock/icons/external_light.png", - iconUrlDark = "https://mock/icons/external_dark.png" + icon = Icon( + light = "https://mock/icons/external_light.png", + dark = "https://mock/icons/external_dark.png" + ) + ), + Recipient( + clazz = "federated", + value = "partner@nextcloud.org", + instance = "nextcloud.org", + displayName = "Partner Cloud", + icon = Icon( + light = "https://mock/icons/federated_light.png", + dark = "https://mock/icons/federated_dark.png" + ) ) ) - return NetworkResult.Success(mock) - } - - override suspend fun createShare( - request: CreateShareRequest - ): NetworkResult { - - val response = ShareDataResponse( - sources = request.data.sources, - recipients = request.data.recipients, - properties = request.data.properties, - id = "mock-share-${System.currentTimeMillis()}", - lastUpdated = System.currentTimeMillis(), - owner = Owner( - userId = "mock-user", - displayName = "Mock User" - ) - ) + val filtered = all + .filter { recipientTypeClass == null || it.clazz == recipientTypeClass } + .filter { it.displayName.contains(query, ignoreCase = true) || it.value.contains(query, ignoreCase = true) } + .drop(offset) + .take(limit) - return NetworkResult.Success(response) + return NetworkResult.Success(filtered) } - override suspend fun fetchShare(id: String): NetworkResult { - - val mock = ShareDataResponse( + override suspend fun createShare(): NetworkResult { + val share = buildShare( + id = "mock-share-${System.currentTimeMillis()}", sources = emptyList(), - recipients = listOf( - ShareUser( - type = "user", - value = "alice@company.com", - displayName = "Alice Johnson" - ) - ), - properties = emptyMap(), - id = id, - lastUpdated = 0, - owner = Owner( - userId = "alice", - displayName = "Alice Johnson" - ) + recipients = emptyList(), + shareState = ShareState.DRAFT ) - - return NetworkResult.Success(mock) + mockShares.add(share) + return NetworkResult.Success(share) } - override suspend fun updateShare( + override suspend fun fetchShare( id: String, - request: UpdateShareRequest - ): NetworkResult { - - val updated = ShareDataResponse( - sources = request.data.sources, - recipients = request.data.recipients, - properties = request.data.properties, - id = id, - lastUpdated = System.currentTimeMillis(), - owner = request.data.owner - ) - - return NetworkResult.Success(updated) + request: GetShareRequest + ): NetworkResult { + val share = mockShares.find { it.id == id } + ?: return NetworkResult.Success( + buildShare(id = id, sources = emptyList(), recipients = emptyList()) + ) + return NetworkResult.Success(share) } override suspend fun deleteShare(id: String): NetworkResult { + mockShares.removeAll { it.id == id } return NetworkResult.Success(Unit) } override suspend fun fetchShares( - sourceType: String?, - lastShareId: String?, + sourceClass: String?, + lastShareID: String?, limit: Int - ): NetworkResult> { - val data = listOf( - UnifiedShare( - id = "1", - sources = emptyList(), - recipients = listOf( - ShareUser( - type = "user", - value = "alice@company.com", - displayName = "Alice Johnson" - ) - ), - properties = emptyMap(), - lastUpdated = 0, - owner = Owner( - userId = "alice", - displayName = "Alice Johnson" - ), - - permission = UnifiedSharePermission.CanView, - label = "Alice Johnson", - note = "Design review – please check latest changes", - password = "", - type = UnifiedShareType.InternalUser, - category = UnifiedShareCategory.Invited, - limit = UnifiedShareDownloadLimit( - limit = 100, - downloadCount = 12 - ) - ), + ): NetworkResult> { + var result = mockShares.toList() - UnifiedShare( - id = "2", - sources = emptyList(), - recipients = listOf( - ShareUser( - type = "group", - value = "marketing", - displayName = "Marketing Team" - ) - ), - properties = emptyMap(), - lastUpdated = 0, - owner = Owner( - userId = "system", - displayName = "System" - ), + if (sourceClass != null) { + result = result.filter { share -> share.sources.any { it.clazz == sourceClass } } + } - permission = UnifiedSharePermission.CanEdit, - label = "Marketing Team", - note = "", - password = "", - type = UnifiedShareType.InternalGroup, - category = UnifiedShareCategory.Invited, - limit = UnifiedShareDownloadLimit( - limit = 0, - downloadCount = 0 - ) - ), + if (lastShareID != null) { + val index = result.indexOfFirst { it.id == lastShareID } + if (index >= 0) result = result.drop(index + 1) + } - UnifiedShare( - id = "3", - sources = listOf( - ShareUser( - type = "link", - value = "https://nextcloud.com/s/abc123", - displayName = "Public Link" - ) - ), - recipients = emptyList(), - properties = emptyMap(), - lastUpdated = 1710000000, - owner = Owner( - userId = "system", - displayName = "System" - ), + return NetworkResult.Success(result.take(limit)) + } - permission = UnifiedSharePermission.Custom( - read = true, - edit = false, - delete = false, - create = false - ), - label = "Public Link", - note = "Public link for client review", - password = "1234", - type = UnifiedShareType.InternalLink, - category = UnifiedShareCategory.Anyone, - limit = UnifiedShareDownloadLimit( - limit = 50, - downloadCount = 5 - ) - ), + override suspend fun updateShareState( + id: String, + request: UpdateShareStateRequest + ): NetworkResult { + val index = mockShares.indexOfFirst { it.id == id } + val updated = (if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList())) + .copy(shareState = request.shareState, lastUpdated = System.currentTimeMillis()) + if (index >= 0) mockShares[index] = updated + return NetworkResult.Success(updated) + } - UnifiedShare( - id = "4", - sources = emptyList(), - recipients = listOf( - ShareUser( - type = "mail", - value = "john@external.com", - displayName = "John External" - ) - ), - properties = emptyMap(), - lastUpdated = 0, - owner = Owner( - userId = "john", - displayName = "John External" - ), + override suspend fun addShareSource( + id: String, + request: AddSourceRequest + ): NetworkResult { + val index = mockShares.indexOfFirst { it.id == id } + val current = if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList()) + val newSource = Source(clazz = request.clazz, value = request.value, displayName = request.value) + val updated = current.copy( + sources = current.sources + newSource, + lastUpdated = System.currentTimeMillis() + ) + if (index >= 0) mockShares[index] = updated + return NetworkResult.Success(updated) + } - permission = UnifiedSharePermission.CanView, - label = "John External", - note = "External partner access", - password = "", - type = UnifiedShareType.ExternalMail, - category = UnifiedShareCategory.Anyone, - limit = UnifiedShareDownloadLimit( - limit = 20, - downloadCount = 2 - ) - ), + override suspend fun removeShareSource( + id: String, + clazz: String, + value: String + ): NetworkResult { + val index = mockShares.indexOfFirst { it.id == id } + val current = if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList()) + val updated = current.copy( + sources = current.sources.filterNot { it.clazz == clazz && it.value == value }, + lastUpdated = System.currentTimeMillis() + ) + if (index >= 0) mockShares[index] = updated + return NetworkResult.Success(updated) + } - UnifiedShare( - id = "5", - sources = emptyList(), - recipients = listOf( - ShareUser( - type = "federated", - value = "partner@nextcloud.org", - displayName = "Partner Cloud" - ) - ), - properties = emptyMap(), - lastUpdated = 0, - owner = Owner( - userId = "partner", - displayName = "Partner Cloud" - ), + override suspend fun addShareRecipient( + id: String, + request: AddRecipientRequest + ): NetworkResult { + val index = mockShares.indexOfFirst { it.id == id } + val current = if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList()) + val newRecipient = Recipient( + clazz = request.clazz, + value = request.value, + instance = request.instance, + displayName = request.value + ) + val updated = current.copy( + recipients = current.recipients + newRecipient, + lastUpdated = System.currentTimeMillis() + ) + if (index >= 0) mockShares[index] = updated + return NetworkResult.Success(updated) + } - permission = UnifiedSharePermission.FileDrop, - label = "Partner Cloud", - note = "Federated sharing with partner instance", - password = "", - type = UnifiedShareType.ExternalFederated, - category = UnifiedShareCategory.Anyone, - limit = UnifiedShareDownloadLimit( - limit = 0, - downloadCount = 0 - ) - ) + override suspend fun removeShareRecipient( + id: String, + clazz: String, + value: String, + instance: String? + ): NetworkResult { + val index = mockShares.indexOfFirst { it.id == id } + val current = if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList()) + val updated = current.copy( + recipients = current.recipients.filterNot { + it.clazz == clazz && it.value == value && it.instance == instance + }, + lastUpdated = System.currentTimeMillis() ) + if (index >= 0) mockShares[index] = updated + return NetworkResult.Success(updated) + } - return NetworkResult.Success(data) + override suspend fun updateShareProperty( + id: String, + request: UpdateSharePropertyRequest + ): NetworkResult { + val index = mockShares.indexOfFirst { it.id == id } + val current = if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList()) + val updated = current.copy(lastUpdated = System.currentTimeMillis()) + if (index >= 0) mockShares[index] = updated + return NetworkResult.Success(updated) + } + + override suspend fun updateSharePermission( + id: String, + request: UpdateSharePermissionRequest + ): NetworkResult { + val index = mockShares.indexOfFirst { it.id == id } + val current = + if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList()) + val updatedPermissions = current.permissions.map { + if (it.clazz == request.clazz) it.copy(enabled = request.enabled) else it + } + val updated = current.copy( + permissions = updatedPermissions, + lastUpdated = System.currentTimeMillis() + ) + if (index >= 0) mockShares[index] = updated + return NetworkResult.Success(updated) } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index 3c7ca0e2..6e0a391e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -13,12 +13,14 @@ import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient import com.nextcloud.android.common.ui.network.model.NetworkResult import com.nextcloud.android.common.ui.network.model.OcsResponse import com.nextcloud.android.common.ui.network.serialization.OCSSerializer -import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest -import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse -import com.nextcloud.android.common.ui.share.model.api.create.toUnifiedShare -import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients -import com.nextcloud.android.common.ui.share.model.api.update.UpdateShareRequest -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient +import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest +import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest +import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest +import com.nextcloud.android.common.ui.share.model.api.share.Share import okhttp3.RequestBody.Companion.toRequestBody class ShareRemoteRepository( @@ -27,75 +29,160 @@ class ShareRemoteRepository( ) : ShareRepository { private companion object { - // Trailing slash intentional, share ID is appended directly: "$SHARE_ENDPOINT$id" - private const val SHARE_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/share/" - + private const val SHARE_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/share" private const val SHARES_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/shares" private const val RECIPIENTS_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/recipients" } override suspend fun fetchRecipients( - recipientType: String, + recipientTypeClass: String?, query: String, limit: Int, offset: Int - ): NetworkResult> = - client.executeRequest( - endpoint = "$RECIPIENTS_ENDPOINT?recipientType=$recipientType&query=$query&limit=$limit&offset=$offset", + ): NetworkResult> { + val queryParams = buildString { + append("?query=$query&limit=$limit&offset=$offset") + recipientTypeClass?.let { append("&recipientTypeClass=$it") } + } + return client.executeRequest( + endpoint = "$RECIPIENTS_ENDPOINT$queryParams", method = HttpMethod.GET ) { body -> - json.decodeFromString>>(body).ocs.data + json.decodeFromString>>(body).ocs.data } + } - override suspend fun createShare(request: CreateShareRequest): NetworkResult = + override suspend fun createShare(): NetworkResult = client.executeRequest( endpoint = SHARE_ENDPOINT, + method = HttpMethod.POST + ) { body -> + json.decodeFromString>(body).ocs.data + } + + override suspend fun fetchShare( + id: String, + request: GetShareRequest + ): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id", method = HttpMethod.POST, body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) ) { body -> - json.decodeFromString>(body).ocs.data + json.decodeFromString>(body).ocs.data } - override suspend fun fetchShare(id: String): NetworkResult = + override suspend fun deleteShare(id: String): NetworkResult = client.executeRequest( - endpoint = "$SHARE_ENDPOINT$id", + endpoint = "$SHARE_ENDPOINT/$id", + method = HttpMethod.DELETE + ) { } + + override suspend fun fetchShares( + sourceClass: String?, + lastShareID: String?, + limit: Int + ): NetworkResult> { + val queryParams = buildString { + append("?limit=$limit") + sourceClass?.let { append("&sourceClass=$it") } + lastShareID?.let { append("&lastShareID=$it") } + } + return client.executeRequest( + endpoint = "$SHARES_ENDPOINT$queryParams", method = HttpMethod.GET ) { body -> - json.decodeFromString>(body).ocs.data + json.decodeFromString>>(body).ocs.data } + } - override suspend fun updateShare(id: String, request: UpdateShareRequest): NetworkResult = + override suspend fun updateShareState( + id: String, + request: UpdateShareStateRequest + ): NetworkResult = client.executeRequest( - endpoint = "$SHARE_ENDPOINT$id", + endpoint = "$SHARE_ENDPOINT/$id/state", method = HttpMethod.PUT, body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) ) { body -> - json.decodeFromString>(body).ocs.data + json.decodeFromString>(body).ocs.data } - override suspend fun deleteShare(id: String): NetworkResult = + override suspend fun addShareSource( + id: String, + request: AddSourceRequest + ): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/source", + method = HttpMethod.POST, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { body -> + json.decodeFromString>(body).ocs.data + } + + override suspend fun removeShareSource( + id: String, + clazz: String, + value: String + ): NetworkResult = client.executeRequest( - endpoint = "$SHARE_ENDPOINT$id", + endpoint = "$SHARE_ENDPOINT/$id/source?class=$clazz&value=$value", method = HttpMethod.DELETE - ) { } + ) { body -> + json.decodeFromString>(body).ocs.data + } - override suspend fun fetchShares( - sourceType: String?, - lastShareId: String?, - limit: Int - ): NetworkResult> { + override suspend fun addShareRecipient( + id: String, + request: AddRecipientRequest + ): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/recipient", + method = HttpMethod.POST, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { body -> + json.decodeFromString>(body).ocs.data + } + + override suspend fun removeShareRecipient( + id: String, + clazz: String, + value: String, + instance: String? + ): NetworkResult { val queryParams = buildString { - append("?limit=$limit") - sourceType?.let { append("&sourceType=$it") } - lastShareId?.let { append("&lastShareId=$it") } + append("?class=$clazz&value=$value") + instance?.let { append("&instance=$it") } } return client.executeRequest( - endpoint = "$SHARES_ENDPOINT$queryParams", - method = HttpMethod.GET + endpoint = "$SHARE_ENDPOINT/$id/recipient$queryParams", + method = HttpMethod.DELETE ) { body -> - json.decodeFromString>>(body) - .ocs.data - .map { it.toUnifiedShare() } + json.decodeFromString>(body).ocs.data } } + + override suspend fun updateShareProperty( + id: String, + request: UpdateSharePropertyRequest + ): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/property", + method = HttpMethod.PUT, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { body -> + json.decodeFromString>(body).ocs.data + } + + override suspend fun updateSharePermission( + id: String, + request: UpdateSharePermissionRequest + ): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/enabled", + method = HttpMethod.PUT, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { body -> + json.decodeFromString>(body).ocs.data + } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt index db90524e..302304bf 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt @@ -4,35 +4,77 @@ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: MIT */ - package com.nextcloud.android.common.ui.share.repository import com.nextcloud.android.common.ui.network.model.NetworkResult -import com.nextcloud.android.common.ui.share.model.api.create.CreateShareRequest -import com.nextcloud.android.common.ui.share.model.api.create.ShareDataResponse -import com.nextcloud.android.common.ui.share.model.api.recipients.ShareRecipients -import com.nextcloud.android.common.ui.share.model.api.update.UpdateShareRequest -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient +import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest +import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest +import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest +import com.nextcloud.android.common.ui.share.model.api.share.Share interface ShareRepository { + suspend fun fetchRecipients( - recipientType: String, + recipientTypeClass: String?, query: String, - limit: Int = 10, - offset: Int = 0 - ): NetworkResult> - - suspend fun createShare(request: CreateShareRequest): NetworkResult + limit: Int, + offset: Int + ): NetworkResult> - suspend fun fetchShare(id: String): NetworkResult + suspend fun createShare(): NetworkResult - suspend fun updateShare(id: String, request: UpdateShareRequest): NetworkResult + suspend fun fetchShare( + id: String, + request: GetShareRequest = GetShareRequest() + ): NetworkResult suspend fun deleteShare(id: String): NetworkResult suspend fun fetchShares( - sourceType: String? = null, - lastShareId: String? = null, - limit: Int = 100 - ): NetworkResult> + sourceClass: String?, + lastShareID: String?, + limit: Int + ): NetworkResult> + + suspend fun updateShareState( + id: String, + request: UpdateShareStateRequest + ): NetworkResult + + suspend fun addShareSource( + id: String, + request: AddSourceRequest + ): NetworkResult + + suspend fun removeShareSource( + id: String, + clazz: String, + value: String + ): NetworkResult + + suspend fun addShareRecipient( + id: String, + request: AddRecipientRequest + ): NetworkResult + + suspend fun removeShareRecipient( + id: String, + clazz: String, + value: String, + instance: String? = null + ): NetworkResult + + suspend fun updateShareProperty( + id: String, + request: UpdateSharePropertyRequest + ): NetworkResult + + suspend fun updateSharePermission( + id: String, + request: UpdateSharePermissionRequest + ): NetworkResult } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index f341dc87..040c51d4 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -33,7 +33,6 @@ Expiration date Hide download and sync options - Label Optional name for this link Expiration date @@ -52,9 +51,12 @@ Send email Delete - Failed to fetch shares + Failed to fetch shares. + + Share not found, cannot delete. + Failed to delete share. - Share not found, cannot delete - Failed to delete share + Failed to create share. + Failed to update share. \ No newline at end of file From 7156afce6be7622b158647f8aa2ceae3f5d544c9 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 15:54:22 +0300 Subject: [PATCH 17/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 11 +-- .../share/model/ui/ShareBottomSheetState.kt | 6 +- .../model/ui/UnifiedShareDownloadLimit.kt | 13 --- .../share/model/ui/UnifiedSharePermission.kt | 79 ------------------- .../ui/share/model/ui/UnifiedShareType.kt | 44 ----------- .../common/ui/share/model/ui/UnifiedShares.kt | 50 ------------ 6 files changed, 10 insertions(+), 193 deletions(-) delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareDownloadLimit.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedSharePermission.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareType.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 1b07956c..cbd0be4f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -90,6 +90,7 @@ import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission import com.nextcloud.android.common.ui.share.model.ui.customPermissionFields import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient +import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.repository.MockShareRepository import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch @@ -111,7 +112,7 @@ private fun ShareView(viewModel: ShareViewModel) { Scaffold(floatingActionButton = { FloatingActionButton( - onClick = { bottomSheetState = ShareBottomSheetState.New(UnifiedShare.new()) }, + onClick = { bottomSheetState = ShareBottomSheetState.New(Share()) }, ) { Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") } @@ -601,11 +602,11 @@ enum class UnifiedSharesListItemType { // NOTE: To just create a public link anyone tab + just send DOES SAME THING @Composable private fun UnifiedSharesListItem( - share: UnifiedShare, + share: Share, type: UnifiedSharesListItemType, - onSelectShare: (UnifiedShare) -> Unit, - onDeleteShare: (UnifiedShare) -> Unit, - onSendEmail: (UnifiedShare) -> Unit + onSelectShare: (Share) -> Unit, + onDeleteShare: (Share) -> Unit, + onSendEmail: (Share) -> Unit ) { var showContextMenu by remember { mutableStateOf(false) } val haptics = LocalHapticFeedback.current diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt index a5bfd600..a0491746 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt @@ -7,8 +7,10 @@ package com.nextcloud.android.common.ui.share.model.ui +import com.nextcloud.android.common.ui.share.model.api.share.Share + sealed class ShareBottomSheetState { data object Idle: ShareBottomSheetState() - data class New(val newShare: UnifiedShare): ShareBottomSheetState() - data class Edit(val share: UnifiedShare): ShareBottomSheetState() + data class New(val newShare: Share): ShareBottomSheetState() + data class Edit(val share: Share): ShareBottomSheetState() } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareDownloadLimit.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareDownloadLimit.kt deleted file mode 100644 index 46a600be..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareDownloadLimit.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.model.ui - -data class UnifiedShareDownloadLimit( - val limit: Int, - val downloadCount: Int -) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedSharePermission.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedSharePermission.kt deleted file mode 100644 index b6511314..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedSharePermission.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.model.ui - -import com.nextcloud.android.common.ui.R - -sealed class UnifiedSharePermission { - // file drop only for folder - data object FileDrop : UnifiedSharePermission() - - data object CanView : UnifiedSharePermission() - data object CanEdit : UnifiedSharePermission() - - // create only for folder - data class Custom(var read: Boolean, var edit: Boolean, var delete: Boolean, var create: Boolean) : - UnifiedSharePermission() { - companion object { - fun getFromPermission(permission: UnifiedSharePermission?): Custom { - return Custom( - permission?.customPermissionRead() == true, - permission?.customPermissionEdit() == true, - permission?.customPermissionDelete() == true, - permission?.customPermissionCreate() == true - ) - } - } - } - - fun getTextId(): Int { - return when(this) { - FileDrop -> R.string.share_permission_file_drop - CanView -> R.string.share_permission_can_view - CanEdit -> R.string.share_permission_can_edit - is Custom -> R.string.share_permission_custom - } - } - - fun customFlag(selector: Custom.() -> Boolean): Boolean = - (this as? Custom)?.selector() ?: false -} - -fun UnifiedSharePermission?.customPermissionRead(): Boolean = this?.customFlag { read } ?: false -fun UnifiedSharePermission?.customPermissionEdit(): Boolean = this?.customFlag { edit } ?: false -fun UnifiedSharePermission?.customPermissionDelete(): Boolean = this?.customFlag { delete } ?: false -fun UnifiedSharePermission?.customPermissionCreate(): Boolean = this?.customFlag { create } ?: false - -data class CustomPermissionField( - val labelRes: Int, - val getValue: (UnifiedSharePermission.Custom) -> Boolean, - val setValue: (UnifiedSharePermission.Custom, Boolean) -> UnifiedSharePermission.Custom -) - -val customPermissionFields = listOf( - CustomPermissionField( - labelRes = R.string.share_view_view_files_switch, - getValue = { it.read }, - setValue = { p, v -> p.copy(read = v) } - ), - CustomPermissionField( - labelRes = R.string.share_view_edit_files_switch, - getValue = { it.edit }, - setValue = { p, v -> p.copy(edit = v) } - ), - CustomPermissionField( - labelRes = R.string.share_view_create_files_switch, - getValue = { it.create }, - setValue = { p, v -> p.copy(create = v) } - ), - CustomPermissionField( - labelRes = R.string.share_view_delete_files_switch, - getValue = { it.delete }, - setValue = { p, v -> p.copy(delete = v) } - ), -) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareType.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareType.kt deleted file mode 100644 index ec3a8acc..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareType.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.model.ui - -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.painterResource -import com.nextcloud.android.common.ui.R - -enum class UnifiedShareType { - InternalUser, InternalGroup, InternalLink, ExternalLink, ExternalFederated, ExternalMail; - - @Composable - fun Icon() { - val iconId = when (this) { - InternalUser -> R.drawable.ic_user - InternalGroup -> R.drawable.ic_group - InternalLink -> R.drawable.ic_email - ExternalLink -> R.drawable.ic_link - ExternalFederated -> R.drawable.ic_group - ExternalMail -> R.drawable.ic_email - } - - Icon(painterResource(iconId), contentDescription = "share type icon") - } - - companion object { - fun toUnifiedShareType(value: String?): UnifiedShareType { - return when (value?.lowercase()) { - "user" -> InternalUser - "group" -> InternalGroup - "link" -> InternalLink - "federated" -> ExternalFederated - "mail" -> ExternalMail - else -> ExternalLink - } - } - } -} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt deleted file mode 100644 index 0e24f7e5..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.model.ui - -import com.nextcloud.android.common.ui.share.model.api.owner.Owner -import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient -import com.nextcloud.android.common.ui.share.model.api.source.Source - -data class UnifiedShare( - val id: String?, - val sources: List, - val recipients: List, - val properties: Map>>, - - val lastUpdated: Long, - val owner: Owner?, - - val permission: UnifiedSharePermission?, - val type: UnifiedShareType?, - val category: UnifiedShareCategory, - val label: String, - val note: String = "", - val password: String = "", - val limit: UnifiedShareDownloadLimit? = null -) { - companion object { - fun new(): UnifiedShare { - return UnifiedShare( - id = null, - sources = listOf(), - recipients = listOf(), - properties = mapOf(), - lastUpdated = -1, - owner = null, - permission = null, - type = null, - category = UnifiedShareCategory.Invited, - label = "", - note = "", - password = "", - limit = null - ) - } - } -} From b69d239d7099d0fc13cfc01f6bf7ccc8156d16aa Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 16:16:01 +0300 Subject: [PATCH 18/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 568 +++++------------- .../ui/share/model/api/property/Property.kt | 9 + .../share/model/ui/ShareBottomSheetState.kt | 5 +- 3 files changed, 150 insertions(+), 432 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index cbd0be4f..d01255bf 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -7,7 +7,6 @@ package com.nextcloud.android.common.ui.share -import android.content.ClipData import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background @@ -17,30 +16,22 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.Button import androidx.compose.material3.ColorScheme import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -66,7 +57,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -74,26 +64,28 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.toClipEntry import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R -import com.nextcloud.android.common.ui.share.model.ui.ShareBottomSheetState -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShare -import com.nextcloud.android.common.ui.share.model.ui.UnifiedShareCategory -import com.nextcloud.android.common.ui.share.model.ui.UnifiedSharePermission -import com.nextcloud.android.common.ui.share.model.ui.customPermissionFields import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient +import com.nextcloud.android.common.ui.share.model.api.property.Property +import com.nextcloud.android.common.ui.share.model.api.property.PropertyBoolean +import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate +import com.nextcloud.android.common.ui.share.model.api.property.PropertyEnum +import com.nextcloud.android.common.ui.share.model.api.property.PropertyPassword +import com.nextcloud.android.common.ui.share.model.api.property.PropertyString +import com.nextcloud.android.common.ui.share.model.api.property.priority import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.ui.ShareBottomSheetState import com.nextcloud.android.common.ui.share.repository.MockShareRepository import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository -import kotlinx.coroutines.launch + @Composable private fun ShareView(viewModel: ShareViewModel) { @@ -110,105 +102,67 @@ private fun ShareView(viewModel: ShareViewModel) { } } - Scaffold(floatingActionButton = { - FloatingActionButton( - onClick = { bottomSheetState = ShareBottomSheetState.New(Share()) }, - ) { - Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") - } - }, snackbarHost = { - SnackbarHost(snackbarHostState) - }) { - LazyColumn(modifier = Modifier.padding(it)) { + Scaffold( + floatingActionButton = { + FloatingActionButton( + onClick = { + // Create an empty draft share immediately on the server, then edit it + viewModel.createShare { draft -> + bottomSheetState = ShareBottomSheetState.Edit(draft) + } + }, + ) { + Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + LazyColumn(modifier = Modifier.padding(paddingValues)) { itemsIndexed(shares) { index, share -> val type = when (index) { - 0 -> { - UnifiedSharesListItemType.Top - } - - shares.lastIndex -> { - UnifiedSharesListItemType.Bottom - } - - else -> { - UnifiedSharesListItemType.Mid - } + 0 -> UnifiedSharesListItemType.Top + shares.lastIndex -> UnifiedSharesListItemType.Bottom + else -> UnifiedSharesListItemType.Mid } - UnifiedSharesListItem(share, type, onSelectShare = { share -> - bottomSheetState = ShareBottomSheetState.Edit(share) - }, onDeleteShare = { - viewModel.deleteShare(share.id) - }, onSendEmail = { - // TODO: - }) + UnifiedSharesListItem( + share = share, + type = type, + onSelectShare = { selected -> bottomSheetState = ShareBottomSheetState.Edit(selected) }, + onDeleteShare = { viewModel.deleteShare(it.id) }, + onSendEmail = { /* TODO */ } + ) } } } - when (bottomSheetState) { - is ShareBottomSheetState.Edit -> { - val state = (bottomSheetState as ShareBottomSheetState.Edit) - AddOrEditShareBottomSheet( - title = stringResource(R.string.share_view_bottom_sheet_edit_title, state.share.label), - share = state.share, - onCreateOrEdit = { - - }, - onDismiss = { bottomSheetState = ShareBottomSheetState.Idle } - ) - } - - is ShareBottomSheetState.New -> { - val state = (bottomSheetState as ShareBottomSheetState.New) - AddOrEditShareBottomSheet( - title = stringResource(R.string.share_view_bottom_sheet_new_title), - share = state.newShare, - onCreateOrEdit = { - - }, - onDismiss = { bottomSheetState = ShareBottomSheetState.Idle } - ) - } - - ShareBottomSheetState.Idle -> Unit + if (bottomSheetState is ShareBottomSheetState.Edit) { + val state = bottomSheetState as ShareBottomSheetState.Edit + AddOrEditShareBottomSheet( + share = state.share, + viewModel = viewModel, + onDismiss = { bottomSheetState = ShareBottomSheetState.Idle } + ) } } -// TODO: Use like inner tags whenever user add a new people to the search and it -// should look like User 1, Group 1 etc. @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AddOrEditShareBottomSheet( - title: String, - share: UnifiedShare, - onCreateOrEdit: () -> Unit, + share: Share, + viewModel: ShareViewModel, onDismiss: () -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scrollState = rememberScrollState() - var category by remember { mutableStateOf(share.category) } - var permission by remember { mutableStateOf(share.permission ?: UnifiedSharePermission.CanView) } - var searchQuery by remember { mutableStateOf("") } - var note by remember { mutableStateOf(share.note) } - - // Toggle states for collapse/expand - var showInvitedSettings by remember { mutableStateOf(false) } - var showAnyoneSettings by remember { mutableStateOf(false) } - - val clipboard = LocalClipboard.current - val context = LocalContext.current - val scope = rememberCoroutineScope() - - val availablePermissions = remember { - listOf( - UnifiedSharePermission.CanView, - UnifiedSharePermission.CanEdit, - UnifiedSharePermission.FileDrop, - UnifiedSharePermission.Custom.getFromPermission(share.permission) - ) + // Group permissions dynamically by Category provided by the backend + val categories = remember(share.permissions) { + share.permissions.mapNotNull { it.category }.distinct() } + var selectedCategory by remember { mutableStateOf(categories.firstOrNull() ?: "") } + + var showAdvancedSettings by remember { mutableStateOf(false) } ModalBottomSheet( onDismissRequest = onDismiss, @@ -224,78 +178,102 @@ private fun AddOrEditShareBottomSheet( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = title, + text = stringResource(R.string.share_view_bottom_sheet_edit_title, share.id), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 8.dp) ) - ShareCategoryButtonGroup( - selectedCategory = category, - onCategoryChange = { category = it } - ) - - if (category == UnifiedShareCategory.Invited) { - InvitedShareContent( - searchQuery = searchQuery, - onSearchChange = { searchQuery = it }, - permission = permission, - availablePermissions = availablePermissions, - onPermissionChange = { permission = it }, - ) - - CollapsibleSettingsSection( - isExpanded = showInvitedSettings, - onToggle = { showInvitedSettings = !showInvitedSettings } - ) { - InvitedInlineSettings(share) + // Dynamic Category Selector + if (categories.size > 1) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + categories.forEachIndexed { index, category -> + SegmentedButton( + selected = selectedCategory == category, + onClick = { selectedCategory = category }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size) + ) { + Text(category) + } + } } - } else { - AnyoneShareContent( - permission = permission, - availablePermissions = availablePermissions, - onPermissionChange = { permission = it }, - ) - - if (permission is UnifiedSharePermission.Custom) { - val customPermissions = permission as UnifiedSharePermission.Custom + } - customPermissionFields.forEach { field -> - SettingsSwitchRow( - label = stringResource(field.labelRes), - checked = field.getValue(customPermissions), - onCheckedChange = { permission = field.setValue(customPermissions, it) } - ) + // Render Permissions for Selected Category + val activePermissions = share.permissions.filter { it.category == selectedCategory } + activePermissions.forEach { permission -> + SettingsSwitchRow( + label = permission.displayName, + checked = permission.enabled, + onCheckedChange = { isChecked -> + viewModel.updatePermission(share.id, permission.clazz, isChecked) } - } + ) + } + // Render Dynamic Properties + if (share.properties.isNotEmpty()) { CollapsibleSettingsSection( - isExpanded = showAnyoneSettings, - onToggle = { showAnyoneSettings = !showAnyoneSettings } + isExpanded = showAdvancedSettings, + onToggle = { showAdvancedSettings = !showAdvancedSettings } ) { - AnyoneInlineSettings(share) + // Sort by server-defined priority + share.properties.sortedBy { it.priority }.forEach { property -> + DynamicPropertyField(share.id, property, viewModel) + } } } + } + } +} - NoteToRecipients(note = note, onNoteChange = { note = it }) - - ShareActionButtons( - share = share, - isSendEnabled = searchQuery.isNotBlank(), - onCopyClick = { - val label = context.getString(R.string.share_view_copy_to_clipboard_label) - - scope.launch { - val clipData = - ClipData.newPlainText(label, it) - clipboard.setClipEntry(clipData.toClipEntry()) - } - }, - onSendClick = { - onCreateOrEdit() +@Composable +private fun DynamicPropertyField(shareId: String, property: Property, viewModel: ShareViewModel) { + when (property) { + is PropertyBoolean -> { + SettingsSwitchRow( + label = property.displayName, + checked = property.value == "true", + onCheckedChange = { isChecked -> + viewModel.updateProperty(shareId, property.clazz, isChecked.toString()) } ) } + is PropertyString -> { + OutlinedTextField( + value = property.value ?: "", + onValueChange = { viewModel.updateProperty(shareId, property.clazz, it) }, + label = { Text(property.displayName) }, + placeholder = property.hint?.let { { Text(it) } }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + singleLine = true + ) + } + is PropertyPassword -> { + OutlinedTextField( + value = property.value ?: "", + onValueChange = { viewModel.updateProperty(shareId, property.clazz, it) }, + label = { Text(property.displayName) }, + placeholder = property.hint?.let { { Text(it) } }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + singleLine = true + ) + } + is PropertyDate -> { + // TODO: Wrap with a DatePickerDialog. Falling back to string entry for now. + OutlinedTextField( + value = property.value ?: "", + onValueChange = { viewModel.updateProperty(shareId, property.clazz, it) }, + label = { Text(property.displayName + " (YYYY-MM-DD)") }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + singleLine = true + ) + } + is PropertyEnum -> { + // TODO: Implement ExposedDropdownMenuBox using property.validValues + Text(text = "Enum Property: ${property.displayName} (Under Construction)", color = Color.Gray) + } } } @@ -334,206 +312,6 @@ private fun CollapsibleSettingsSection( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ShareCategoryButtonGroup( - selectedCategory: UnifiedShareCategory, - onCategoryChange: (UnifiedShareCategory) -> Unit -) { - SingleChoiceSegmentedButtonRow( - modifier = Modifier.fillMaxWidth() - ) { - UnifiedShareCategory.entries.forEachIndexed { index, option -> - SegmentedButton( - selected = selectedCategory == option, - onClick = { onCategoryChange(option) }, - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = UnifiedShareCategory.entries.size - ) - ) { - Text(option.name) - } - } - } -} - -@Composable -private fun InvitedShareContent( - searchQuery: String, - onSearchChange: (String) -> Unit, - permission: UnifiedSharePermission, - availablePermissions: List, - onPermissionChange: (UnifiedSharePermission) -> Unit, - - ) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - OutlinedTextField( - value = searchQuery, - onValueChange = onSearchChange, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.share_view_invited_category_label)) }, - placeholder = { Text(stringResource(R.string.share_view_invited_category_placeholder)) }, - singleLine = true, - shape = RoundedCornerShape(8.dp) - ) - - PermissionDropdown( - label = stringResource(R.string.share_view_invited_category_participants), - selectedPermission = permission, - availablePermissions = availablePermissions, - onPermissionChange = onPermissionChange - ) - } -} - -@Composable -private fun NoteToRecipients( - note: String, - onNoteChange: (String) -> Unit -) { - OutlinedTextField( - value = note, - onValueChange = onNoteChange, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text(stringResource(R.string.share_view_note_text_field_placeholder)) }, - shape = RoundedCornerShape(8.dp) - ) -} - -@Composable -private fun AnyoneShareContent( - permission: UnifiedSharePermission, - availablePermissions: List, - onPermissionChange: (UnifiedSharePermission) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - PermissionDropdown( - label = stringResource(R.string.share_view_permission_dropdown_label), - selectedPermission = permission, - availablePermissions = availablePermissions, - onPermissionChange = onPermissionChange - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun PermissionDropdown( - label: String, - selectedPermission: UnifiedSharePermission, - availablePermissions: List, - onPermissionChange: (UnifiedSharePermission) -> Unit -) { - var expanded by remember { mutableStateOf(false) } - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - value = stringResource(selectedPermission.getTextId()), - onValueChange = {}, - readOnly = true, - label = { Text(label) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - availablePermissions.forEach { option -> - DropdownMenuItem( - text = { Text(stringResource(option.getTextId())) }, - onClick = { - onPermissionChange(option) - expanded = false - } - ) - } - } - } -} - -@Composable -private fun InvitedInlineSettings(share: UnifiedShare) { - var shareWithOthers by remember { mutableStateOf(share.recipients.isNotEmpty()) } - var editFile by remember { mutableStateOf((share.permission as? UnifiedSharePermission.CanEdit) != null) } - var hasExpiration by remember { mutableStateOf(false) } // TODO - var hideDownload by remember { mutableStateOf(false) } // TODO - - SettingsSwitchRow( - stringResource(R.string.share_view_invited_category_share_with_others_switch), - shareWithOthers - ) { shareWithOthers = it } - SettingsSwitchRow(stringResource(R.string.share_view_invited_category_edit_file_switch), editFile) { editFile = it } - SettingsSwitchRow( - stringResource(R.string.share_view_invited_category_expiration_date_switch), - hasExpiration - ) { hasExpiration = it } - SettingsSwitchRow( - stringResource(R.string.share_view_invited_category_hide_and_download_switch), - hideDownload - ) { hideDownload = it } -} - -@Composable -private fun AnyoneInlineSettings(share: UnifiedShare) { - var hasPassword by remember { mutableStateOf(share.password.isNotEmpty()) } - var hasExpiration by remember { mutableStateOf(false) } - var limitDownloads by remember { mutableStateOf(share.limit != null) } - - var hideDownloads by remember { mutableStateOf(false) } - var videoVerification by remember { mutableStateOf(false) } - var showFilesInGridView by remember { mutableStateOf(false) } - - OutlinedTextField( - value = "", - onValueChange = {}, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - label = { Text(stringResource(R.string.share_view_anyone_category_label)) }, - placeholder = { Text(stringResource(R.string.share_view_anyone_category_label_placeholder)) }, - singleLine = true - ) - - SettingsSwitchRow( - stringResource(R.string.share_view_anyone_category_expiration_date_switch), - hasExpiration - ) { hasExpiration = it } - - SettingsSwitchRow( - stringResource(R.string.share_view_anyone_category_password_switch), - hasPassword - ) { hasPassword = it } - - SettingsSwitchRow( - stringResource(R.string.share_view_anyone_category_limit_downloads_switch), - limitDownloads - ) { limitDownloads = it } - - SettingsSwitchRow( - stringResource(R.string.share_view_anyone_category_hide_downloads_switch), - hideDownloads - ) { hideDownloads = it } - - SettingsSwitchRow( - stringResource(R.string.share_view_anyone_category_video_verification_switch), - videoVerification - ) { videoVerification = it } - - SettingsSwitchRow( - stringResource(R.string.share_view_anyone_category_grid_view_switch), - showFilesInGridView - ) { showFilesInGridView = it } -} - @Composable private fun SettingsSwitchRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { Row( @@ -548,47 +326,8 @@ private fun SettingsSwitchRow(label: String, checked: Boolean, onCheckedChange: } } -@Composable -private fun ShareActionButtons( - share: UnifiedShare, - isSendEnabled: Boolean, - onCopyClick: (String) -> Unit, - onSendClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) - ) { - if (share.category == UnifiedShareCategory.Invited) { - FilledTonalButton( - onClick = { onCopyClick("TODO") }, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(R.string.share_view_copy_action)) - } - Spacer(modifier = Modifier.width(16.dp)) - Button( - onClick = onSendClick, - modifier = Modifier.weight(1f), - enabled = isSendEnabled - ) { - Text(stringResource(R.string.share_view_send_action)) - } - } else { - Button( - onClick = { onCopyClick("TODO") }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.share_view_create_public_link)) - } - } - } -} - enum class UnifiedSharesListItemType { Top, Mid, Bottom; - @Composable fun getShape(): RoundedCornerShape { return when (this) { @@ -599,7 +338,6 @@ enum class UnifiedSharesListItemType { } } -// NOTE: To just create a public link anyone tab + just send DOES SAME THING @Composable private fun UnifiedSharesListItem( share: Share, @@ -623,33 +361,18 @@ private fun UnifiedSharesListItem( }, ) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), - leadingContent = { - share.type?.let { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center - ) { - it.Icon() - } - } - }, headlineContent = { Text( - text = share.label, + text = "Share ${share.id}", style = MaterialTheme.typography.titleSmall ) }, supportingContent = { - share.permission?.getTextId()?.let { - Text( - text = stringResource(it), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Text( + text = share.shareState.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) }, trailingContent = { Box { @@ -657,10 +380,7 @@ private fun UnifiedSharesListItem( Icon(Icons.Default.MoreVert, contentDescription = "More options") } - DropdownMenu( - expanded = showContextMenu, - onDismissRequest = { showContextMenu = false } - ) { + DropdownMenu(expanded = showContextMenu, onDismissRequest = { showContextMenu = false }) { DropdownMenuItem( text = { Text(stringResource(R.string.share_view_list_item_edit)) }, onClick = { @@ -668,7 +388,6 @@ private fun UnifiedSharesListItem( onSelectShare(share) } ) - DropdownMenuItem( text = { Text(stringResource(R.string.share_view_list_item_send_email)) }, onClick = { @@ -676,16 +395,9 @@ private fun UnifiedSharesListItem( showContextMenu = false } ) - HorizontalDivider() - DropdownMenuItem( - text = { - Text( - stringResource(R.string.share_view_list_item_delete), - color = MaterialTheme.colorScheme.error - ) - }, + text = { Text(stringResource(R.string.share_view_list_item_delete), color = MaterialTheme.colorScheme.error) }, onClick = { onDeleteShare(share) showContextMenu = false @@ -694,9 +406,7 @@ private fun UnifiedSharesListItem( } } }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent - ) + colors = ListItemDefaults.colors(containerColor = Color.Transparent) ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt index 1520f771..3dacf64f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt @@ -17,6 +17,15 @@ import kotlinx.serialization.json.JsonClassDiscriminator @Serializable sealed class Property +val Property.priority: Int + get() = when (this) { + is PropertyBoolean -> priority + is PropertyDate -> priority + is PropertyEnum -> priority + is PropertyPassword -> priority + is PropertyString -> priority + } + @Serializable @SerialName("boolean") data class PropertyBoolean( diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt index a0491746..a2c62c28 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt @@ -10,7 +10,6 @@ package com.nextcloud.android.common.ui.share.model.ui import com.nextcloud.android.common.ui.share.model.api.share.Share sealed class ShareBottomSheetState { - data object Idle: ShareBottomSheetState() - data class New(val newShare: Share): ShareBottomSheetState() - data class Edit(val share: Share): ShareBottomSheetState() + object Idle : ShareBottomSheetState() + data class Edit(val share: Share) : ShareBottomSheetState() } From 9d74f5b8a0b08a16aa7cc5a1fc319733a93ea2c2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 16:49:33 +0300 Subject: [PATCH 19/95] add empty view state Signed-off-by: alperozturk96 --- .../ui/component/ContentUnavailableView.kt | 64 ++++++++++++++++++ .../android/common/ui/share/ShareView.kt | 66 +++++++++++++------ .../android/common/ui/share/ShareViewModel.kt | 4 +- .../common/ui/share/model/api/share/Share.kt | 1 + ui/src/main/res/values/strings.xml | 3 + 5 files changed, 116 insertions(+), 22 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/component/ContentUnavailableView.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/component/ContentUnavailableView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/component/ContentUnavailableView.kt new file mode 100644 index 00000000..3d28ffc2 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/component/ContentUnavailableView.kt @@ -0,0 +1,64 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun ContentUnavailableView(title: String, description: String? = null, iconId: Int? = null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + iconId?.let { + Image( + painter = painterResource(iconId), + modifier = Modifier.size(48.dp), + contentDescription = "", + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + description?.let { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = description, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index d01255bf..45ab5d60 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -72,6 +72,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.component.ContentUnavailableView import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient import com.nextcloud.android.common.ui.share.model.api.property.Property @@ -86,7 +87,6 @@ import com.nextcloud.android.common.ui.share.model.ui.ShareBottomSheetState import com.nextcloud.android.common.ui.share.repository.MockShareRepository import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository - @Composable private fun ShareView(viewModel: ShareViewModel) { val errorMessageId by viewModel.errorMessageId.collectAsState() @@ -115,23 +115,33 @@ private fun ShareView(viewModel: ShareViewModel) { Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") } }, - snackbarHost = { SnackbarHost(snackbarHostState) } + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = Color.Transparent ) { paddingValues -> - LazyColumn(modifier = Modifier.padding(paddingValues)) { - itemsIndexed(shares) { index, share -> - val type = when (index) { - 0 -> UnifiedSharesListItemType.Top - shares.lastIndex -> UnifiedSharesListItemType.Bottom - else -> UnifiedSharesListItemType.Mid - } + if (shares.isEmpty()) { + ContentUnavailableView( + iconId = R.drawable.ic_person_add, + title = + stringResource(R.string.share_view_empty_title), + description = stringResource(R.string.share_view_empty_description) + ) + } else { + LazyColumn(modifier = Modifier.padding(paddingValues)) { + itemsIndexed(shares) { index, share -> + val type = when (index) { + 0 -> UnifiedSharesListItemType.Top + shares.lastIndex -> UnifiedSharesListItemType.Bottom + else -> UnifiedSharesListItemType.Mid + } - UnifiedSharesListItem( - share = share, - type = type, - onSelectShare = { selected -> bottomSheetState = ShareBottomSheetState.Edit(selected) }, - onDeleteShare = { viewModel.deleteShare(it.id) }, - onSendEmail = { /* TODO */ } - ) + UnifiedSharesListItem( + share = share, + type = type, + onSelectShare = { selected -> bottomSheetState = ShareBottomSheetState.Edit(selected) }, + onDeleteShare = { viewModel.deleteShare(it.id) }, + onSendEmail = { /* TODO */ } + ) + } } } } @@ -239,16 +249,20 @@ private fun DynamicPropertyField(shareId: String, property: Property, viewModel: } ) } + is PropertyString -> { OutlinedTextField( value = property.value ?: "", onValueChange = { viewModel.updateProperty(shareId, property.clazz, it) }, label = { Text(property.displayName) }, placeholder = property.hint?.let { { Text(it) } }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), singleLine = true ) } + is PropertyPassword -> { OutlinedTextField( value = property.value ?: "", @@ -256,20 +270,26 @@ private fun DynamicPropertyField(shareId: String, property: Property, viewModel: label = { Text(property.displayName) }, placeholder = property.hint?.let { { Text(it) } }, visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), singleLine = true ) } + is PropertyDate -> { // TODO: Wrap with a DatePickerDialog. Falling back to string entry for now. OutlinedTextField( value = property.value ?: "", onValueChange = { viewModel.updateProperty(shareId, property.clazz, it) }, label = { Text(property.displayName + " (YYYY-MM-DD)") }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), singleLine = true ) } + is PropertyEnum -> { // TODO: Implement ExposedDropdownMenuBox using property.validValues Text(text = "Enum Property: ${property.displayName} (Under Construction)", color = Color.Gray) @@ -328,6 +348,7 @@ private fun SettingsSwitchRow(label: String, checked: Boolean, onCheckedChange: enum class UnifiedSharesListItemType { Top, Mid, Bottom; + @Composable fun getShape(): RoundedCornerShape { return when (this) { @@ -397,7 +418,12 @@ private fun UnifiedSharesListItem( ) HorizontalDivider() DropdownMenuItem( - text = { Text(stringResource(R.string.share_view_list_item_delete), color = MaterialTheme.colorScheme.error) }, + text = { + Text( + stringResource(R.string.share_view_list_item_delete), + color = MaterialTheme.colorScheme.error + ) + }, onClick = { onDeleteShare(share) showContextMenu = false diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index ef712815..15ad093b 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -43,11 +43,11 @@ class ShareViewModel( val errorMessageId: StateFlow = _errorMessageId init { - loadShares() + fetchShares() } // region shares list - fun loadShares( + fun fetchShares( sourceClass: String? = null, lastShareID: String? = null, limit: Int = 50 diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 8b67d24d..0d59ab31 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -25,6 +25,7 @@ data class Share( @SerialName("last_updated") val lastUpdated: Long, + @SerialName("state") val shareState: ShareState, val sources: List, diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 040c51d4..63441395 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -24,6 +24,9 @@ Name, team, email or federated ID Participants + No shares yet + Start sharing to see them in this list. + Note to recipients Anyone with the link From 4649ac2f7c3bb5e3867807ff202aa7da24806c3b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 17:02:32 +0300 Subject: [PATCH 20/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 16 +++++++++++++--- .../ui/share/repository/ShareRemoteRepository.kt | 3 ++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 45ab5d60..e3f369b9 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -126,7 +127,12 @@ private fun ShareView(viewModel: ShareViewModel) { description = stringResource(R.string.share_view_empty_description) ) } else { - LazyColumn(modifier = Modifier.padding(paddingValues)) { + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { itemsIndexed(shares) { index, share -> val type = when (index) { 0 -> UnifiedSharesListItemType.Top @@ -134,6 +140,10 @@ private fun ShareView(viewModel: ShareViewModel) { else -> UnifiedSharesListItemType.Mid } + if (index == 0) { + Spacer(modifier = Modifier.height(16.dp)) + } + UnifiedSharesListItem( share = share, type = type, @@ -372,7 +382,7 @@ private fun UnifiedSharesListItem( ListItem( modifier = Modifier - .fillMaxWidth() + .fillMaxWidth(0.9f) .clip(type.getShape()) .combinedClickable( onClick = { onSelectShare(share) }, @@ -384,7 +394,7 @@ private fun UnifiedSharesListItem( .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), headlineContent = { Text( - text = "Share ${share.id}", + text = "Share ${share.id}", // TODO do not hardcode style = MaterialTheme.typography.titleSmall ) }, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index 6e0a391e..e5574bf7 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -55,7 +55,8 @@ class ShareRemoteRepository( override suspend fun createShare(): NetworkResult = client.executeRequest( endpoint = SHARE_ENDPOINT, - method = HttpMethod.POST + method = HttpMethod.POST, + body = ByteArray(0).toRequestBody() ) { body -> json.decodeFromString>(body).ocs.data } From 3408e393f83585c2f3a6c7b7777f42e5f726399d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 28 May 2026 15:04:34 +0300 Subject: [PATCH 21/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 195 ++++++++++++++---- .../android/common/ui/share/ShareViewModel.kt | 45 +++- .../common/ui/share/model/api/share/Share.kt | 13 +- .../share/model/ui/ShareBottomSheetState.kt | 15 -- ...ifiedShareCategory.kt => ShareCategory.kt} | 2 +- ui/src/main/res/values/strings.xml | 5 +- 6 files changed, 207 insertions(+), 68 deletions(-) delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt rename ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/{UnifiedShareCategory.kt => ShareCategory.kt} (87%) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index e3f369b9..218cfc10 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -12,6 +12,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,12 +21,14 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.MoreVert @@ -33,10 +36,13 @@ import androidx.compose.material3.ColorScheme import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -84,17 +90,19 @@ import com.nextcloud.android.common.ui.share.model.api.property.PropertyPassword import com.nextcloud.android.common.ui.share.model.api.property.PropertyString import com.nextcloud.android.common.ui.share.model.api.property.priority import com.nextcloud.android.common.ui.share.model.api.share.Share -import com.nextcloud.android.common.ui.share.model.ui.ShareBottomSheetState +import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.ui.ShareCategory import com.nextcloud.android.common.ui.share.repository.MockShareRepository import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository @Composable private fun ShareView(viewModel: ShareViewModel) { val errorMessageId by viewModel.errorMessageId.collectAsState() - var bottomSheetState by remember { mutableStateOf(ShareBottomSheetState.Idle) } val shares by viewModel.shares.collectAsState() + val activeShare by viewModel.activeShare.collectAsState() val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } + val filteredShares = shares.filter { it.shareState != ShareState.DRAFT } LaunchedEffect(errorMessageId) { errorMessageId?.let { @@ -107,10 +115,7 @@ private fun ShareView(viewModel: ShareViewModel) { floatingActionButton = { FloatingActionButton( onClick = { - // Create an empty draft share immediately on the server, then edit it - viewModel.createShare { draft -> - bottomSheetState = ShareBottomSheetState.Edit(draft) - } + viewModel.createShare() }, ) { Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") @@ -119,12 +124,11 @@ private fun ShareView(viewModel: ShareViewModel) { snackbarHost = { SnackbarHost(snackbarHostState) }, containerColor = Color.Transparent ) { paddingValues -> - if (shares.isEmpty()) { + if (filteredShares.isEmpty()) { ContentUnavailableView( iconId = R.drawable.ic_person_add, title = stringResource(R.string.share_view_empty_title), - description = stringResource(R.string.share_view_empty_description) ) } else { LazyColumn( @@ -133,7 +137,7 @@ private fun ShareView(viewModel: ShareViewModel) { .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - itemsIndexed(shares) { index, share -> + itemsIndexed(filteredShares) { index, share -> val type = when (index) { 0 -> UnifiedSharesListItemType.Top shares.lastIndex -> UnifiedSharesListItemType.Bottom @@ -147,21 +151,21 @@ private fun ShareView(viewModel: ShareViewModel) { UnifiedSharesListItem( share = share, type = type, - onSelectShare = { selected -> bottomSheetState = ShareBottomSheetState.Edit(selected) }, + onSelectShare = { selected -> + viewModel.setActiveShare(selected) + }, onDeleteShare = { viewModel.deleteShare(it.id) }, - onSendEmail = { /* TODO */ } + onSendEmail = { } ) } } } } - if (bottomSheetState is ShareBottomSheetState.Edit) { - val state = bottomSheetState as ShareBottomSheetState.Edit + activeShare?.let { AddOrEditShareBottomSheet( - share = state.share, - viewModel = viewModel, - onDismiss = { bottomSheetState = ShareBottomSheetState.Idle } + share = it, + viewModel = viewModel ) } } @@ -170,22 +174,19 @@ private fun ShareView(viewModel: ShareViewModel) { @Composable private fun AddOrEditShareBottomSheet( share: Share, - viewModel: ShareViewModel, - onDismiss: () -> Unit + viewModel: ShareViewModel ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scrollState = rememberScrollState() - - // Group permissions dynamically by Category provided by the backend - val categories = remember(share.permissions) { - share.permissions.mapNotNull { it.category }.distinct() - } - var selectedCategory by remember { mutableStateOf(categories.firstOrNull() ?: "") } - + val context = LocalContext.current + val categories = remember { ShareCategory.entries.toList() } + var selectedCategory by remember { mutableStateOf(categories.first()) } var showAdvancedSettings by remember { mutableStateOf(false) } ModalBottomSheet( - onDismissRequest = onDismiss, + onDismissRequest = { + viewModel.setActiveShare(null) + }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, ) { @@ -198,30 +199,29 @@ private fun AddOrEditShareBottomSheet( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = stringResource(R.string.share_view_bottom_sheet_edit_title, share.id), + text = share.title(context), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 8.dp) ) - // Dynamic Category Selector - if (categories.size > 1) { - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - categories.forEachIndexed { index, category -> - SegmentedButton( - selected = selectedCategory == category, - onClick = { selectedCategory = category }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size) - ) { - Text(category) - } + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + categories.forEachIndexed { index, category -> + SegmentedButton( + selected = selectedCategory == category, + onClick = { selectedCategory = category }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size) + ) { + Text(category.name) } } } - // Render Permissions for Selected Category - val activePermissions = share.permissions.filter { it.category == selectedCategory } - activePermissions.forEach { permission -> + if (selectedCategory == ShareCategory.Invited) { + RecipientSearchField(share, viewModel) + } + + share.permissions.forEach { permission -> SettingsSwitchRow( label = permission.displayName, checked = permission.enabled, @@ -231,7 +231,6 @@ private fun AddOrEditShareBottomSheet( ) } - // Render Dynamic Properties if (share.properties.isNotEmpty()) { CollapsibleSettingsSection( isExpanded = showAdvancedSettings, @@ -247,6 +246,110 @@ private fun AddOrEditShareBottomSheet( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RecipientSearchField( + share: Share, + viewModel: ShareViewModel +) { + var query by remember { mutableStateOf("") } + var expanded by remember { mutableStateOf(false) } + val results by viewModel.recipientSearchResults.collectAsState() + val chipScrollState = rememberScrollState() + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (share.recipients.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(chipScrollState), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + share.recipients.forEach { recipient -> + InputChip( + selected = true, + onClick = { }, + label = { Text(recipient.displayName) }, + trailingIcon = { + IconButton( + onClick = { + viewModel.removeRecipient( + id = share.id, + clazz = recipient.clazz, + value = recipient.value, + instance = recipient.instance + ) + }, + modifier = Modifier.size(16.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "remove recipient" + ) + } + } + ) + } + } + } + + ExposedDropdownMenuBox( + expanded = expanded && query.isNotBlank(), + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = query, + onValueChange = { + query = it + expanded = true + viewModel.onSearchQueryChanged(it) + }, + label = { Text(stringResource(R.string.share_view_invited_category_label)) }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true) + .fillMaxWidth(), + singleLine = true + ) + + if (query.isNotBlank()) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + if (results.isEmpty()) { + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.share_view_recipient_search_field_empty_result), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = {}, + enabled = false + ) + } else { + results.forEach { recipient -> + DropdownMenuItem( + text = { Text(recipient.displayName) }, + onClick = { + viewModel.addRecipient(share.id, recipient.clazz, recipient.value) + query = "" + expanded = false + } + ) + } + } + } + } + } + } +} + @Composable private fun DynamicPropertyField(shareId: String, property: Property, viewModel: ShareViewModel) { when (property) { @@ -393,8 +496,14 @@ private fun UnifiedSharesListItem( ) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), headlineContent = { + val headline = if (share.recipients.isNotEmpty()) { + share.recipients.first().displayName + } else { + "" + } + Text( - text = "Share ${share.id}", // TODO do not hardcode + text = headline, style = MaterialTheme.typography.titleSmall ) }, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 15ad093b..fae1d7b8 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.network.model.NetworkResult +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest @@ -21,8 +22,12 @@ import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.repository.ShareRepository import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -36,6 +41,11 @@ class ShareViewModel( private val _activeShare = MutableStateFlow(null) val activeShare: StateFlow = _activeShare + private val _searchQuery = MutableStateFlow("") + + private val _recipientSearchResults = MutableStateFlow>(emptyList()) + val recipientSearchResults: StateFlow> = _recipientSearchResults + private val _loading = MutableStateFlow(false) val loading: StateFlow = _loading @@ -44,7 +54,34 @@ class ShareViewModel( init { fetchShares() + initSearchQuery() + } + + // region search query + @OptIn(FlowPreview::class) + private fun initSearchQuery() { + viewModelScope.launch { + _searchQuery + .debounce(300L) + .distinctUntilChanged() + .filter { it.isNotBlank() } + .collect { query -> + executeSearch(query) + } + } + } + + fun onSearchQueryChanged(query: String) { + _searchQuery.value = query + } + + private suspend fun executeSearch(query: String) { + val result = repository.fetchRecipients(null, query, 10, 0) + if (result is NetworkResult.Success) { + _recipientSearchResults.value = result.data + } } + // endregion // region shares list fun fetchShares( @@ -84,7 +121,7 @@ class ShareViewModel( * Then sources can be added later. * */ - fun createShare(onCreated: (Share) -> Unit = {}) { + fun createShare() { launchWithLoading { handleResult( result = repository.createShare(), @@ -92,7 +129,6 @@ class ShareViewModel( ) { draft -> _activeShare.update { draft } _shares.update { current -> listOf(draft) + current } - onCreated(draft) } } } @@ -211,8 +247,8 @@ class ShareViewModel( _errorMessageId.update { value } } - fun clearActiveShare() { - _activeShare.update { null } + fun setActiveShare(value: Share?) { + _activeShare.update { value } } // endregion @@ -241,6 +277,5 @@ class ShareViewModel( _loading.update { false } } } - // endregion } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 0d59ab31..3d2032e4 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -7,6 +7,8 @@ package com.nextcloud.android.common.ui.share.model.api.share +import android.content.Context +import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.model.api.owner.Owner import com.nextcloud.android.common.ui.share.model.api.permission.Permission import com.nextcloud.android.common.ui.share.model.api.property.Property @@ -35,4 +37,13 @@ data class Share( val properties: List, val permissions: List -) +) { + fun title(context: Context): String { + return if (shareState == ShareState.DRAFT) { + context.getString(R.string.share_view_bottom_sheet_new_title) + } else { + // TODO do not hardcode + context.getString(R.string.share_view_bottom_sheet_edit_title, "") + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt deleted file mode 100644 index a2c62c28..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareBottomSheetState.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.model.ui - -import com.nextcloud.android.common.ui.share.model.api.share.Share - -sealed class ShareBottomSheetState { - object Idle : ShareBottomSheetState() - data class Edit(val share: Share) : ShareBottomSheetState() -} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareCategory.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt similarity index 87% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareCategory.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt index e93bd3d4..491b1b57 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareCategory.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt @@ -7,6 +7,6 @@ package com.nextcloud.android.common.ui.share.model.ui -enum class UnifiedShareCategory { +enum class ShareCategory { Invited, Anyone } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 63441395..0c60cec0 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -24,9 +24,8 @@ Name, team, email or federated ID Participants - No shares yet - Start sharing to see them in this list. - + Not shared + No result Note to recipients Anyone with the link From dadb0f0633aeb7f54836cf2558d0164121fad558 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 28 May 2026 15:48:42 +0300 Subject: [PATCH 22/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 21 ++- .../android/common/ui/share/ShareViewModel.kt | 132 ++++++++---------- .../share/repository/MockShareRepository.kt | 2 +- .../share/repository/ShareRemoteRepository.kt | 2 +- .../ui/share/repository/ShareRepository.kt | 2 +- 5 files changed, 77 insertions(+), 82 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 218cfc10..8fbe1c72 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -64,6 +64,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -94,13 +95,15 @@ import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.model.ui.ShareCategory import com.nextcloud.android.common.ui.share.repository.MockShareRepository import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository +import kotlinx.coroutines.launch @Composable -private fun ShareView(viewModel: ShareViewModel) { +private fun ShareView(sourceId: String, viewModel: ShareViewModel) { val errorMessageId by viewModel.errorMessageId.collectAsState() val shares by viewModel.shares.collectAsState() val activeShare by viewModel.activeShare.collectAsState() val context = LocalContext.current + val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } val filteredShares = shares.filter { it.shareState != ShareState.DRAFT } @@ -115,7 +118,11 @@ private fun ShareView(viewModel: ShareViewModel) { floatingActionButton = { FloatingActionButton( onClick = { - viewModel.createShare() + scope.launch { + viewModel.createDraftShare()?.let { + viewModel.addSource(it.id, sourceId) + } + } }, ) { Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") @@ -272,7 +279,7 @@ private fun RecipientSearchField( share.recipients.forEach { recipient -> InputChip( selected = true, - onClick = { }, + onClick = { }, label = { Text(recipient.displayName) }, trailingIcon = { IconButton( @@ -559,7 +566,7 @@ private fun UnifiedSharesListItem( @Composable private fun PreviewLight() { PreviewTheme { - ShareView(viewModel = ShareViewModel(MockShareRepository())) + ShareView(sourceId = "", viewModel = ShareViewModel(MockShareRepository())) } } @@ -567,7 +574,7 @@ private fun PreviewLight() { @Composable private fun PreviewDark() { PreviewTheme(darkTheme = true) { - ShareView(viewModel = ShareViewModel(MockShareRepository())) + ShareView(sourceId = "", viewModel = ShareViewModel(MockShareRepository())) } } @@ -581,7 +588,7 @@ private fun PreviewTheme( } } -fun ComposeView.setupUnifiedShare(colorScheme: ColorScheme, credentials: ServerCredentials) { +fun ComposeView.setupUnifiedShare(sourceId: String, credentials: ServerCredentials, colorScheme: ColorScheme) { val nextcloudHttpClient = NextcloudHttpClient.create(credentials) val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)) @@ -589,7 +596,7 @@ fun ComposeView.setupUnifiedShare(colorScheme: ColorScheme, credentials: ServerC MaterialTheme( colorScheme = colorScheme, content = { - ShareView(viewModel) + ShareView(sourceId, viewModel) } ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index fae1d7b8..36f5502c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class ShareViewModel( private val repository: ShareRepository @@ -89,47 +90,56 @@ class ShareViewModel( lastShareID: String? = null, limit: Int = 50 ) { - launchWithLoading { - handleResult( - result = repository.fetchShares( - sourceClass = sourceClass, - lastShareID = lastShareID, - limit = limit - ), - errorId = R.string.share_view_fetch_error_message - ) { _shares.update { it } } + viewModelScope.launch(Dispatchers.IO) { + _loading.update { true } + _errorMessageId.update { null } + + val result = repository.fetchShares(sourceClass, lastShareID, limit) + handleResult(result, R.string.share_view_fetch_error_message)?.let { fetchedShares -> + _shares.update { fetchedShares } + } + + _loading.update { false } } } fun fetchShare(id: String, request: GetShareRequest = GetShareRequest()) { - launchWithLoading { - handleResult( - result = repository.fetchShare(id, request), - errorId = R.string.share_view_fetch_error_message - ) { share -> + viewModelScope.launch(Dispatchers.IO) { + _loading.update { true } + _errorMessageId.update { null } + + val result = repository.fetchShare(id, request) + handleResult(result, R.string.share_view_fetch_error_message)?.let { share -> _activeShare.update { share } replaceInList(share) } + + _loading.update { false } } } // endregion // region create - /** * Creates an empty draft [Share] on the server and sets it as the [activeShare]. * Then sources can be added later. * */ - fun createShare() { - launchWithLoading { - handleResult( - result = repository.createShare(), - errorId = R.string.share_view_create_error_message - ) { draft -> - _activeShare.update { draft } - _shares.update { current -> listOf(draft) + current } - } + suspend fun createDraftShare(): Share? = withContext(Dispatchers.IO) { + _loading.update { true } + _errorMessageId.update { null } + + val result = repository.createDraftShare() + val draft = handleResult(result, R.string.share_view_create_error_message) + + if (draft != null) { + _activeShare.update { draft } + _shares.update { current -> listOf(draft) + current } + _loading.update { false } + draft + } else { + _loading.update { false } + null } } // endregion @@ -137,10 +147,8 @@ class ShareViewModel( // region state fun updateState(id: String, shareState: ShareState) { viewModelScope.launch(Dispatchers.IO) { - handleResult( - result = repository.updateShareState(id, UpdateShareStateRequest(shareState)), - errorId = R.string.share_view_update_error_message - ) { updated -> + val result = repository.updateShareState(id, UpdateShareStateRequest(shareState)) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> _activeShare.update { updated } replaceInList(updated) } @@ -149,12 +157,11 @@ class ShareViewModel( // endregion // region sources - fun addSource(id: String, clazz: String, value: String) { + fun addSource(id: String, value: String) { viewModelScope.launch(Dispatchers.IO) { - handleResult( - result = repository.addShareSource(id, AddSourceRequest(clazz, value)), - errorId = R.string.share_view_update_error_message - ) { updated -> + val clazz = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType" + val result = repository.addShareSource(id, AddSourceRequest(clazz, value)) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> _activeShare.update { updated } replaceInList(updated) } @@ -163,10 +170,8 @@ class ShareViewModel( fun removeSource(id: String, clazz: String, value: String) { viewModelScope.launch(Dispatchers.IO) { - handleResult( - result = repository.removeShareSource(id, clazz, value), - errorId = R.string.share_view_update_error_message - ) { updated -> + val result = repository.removeShareSource(id, clazz, value) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> _activeShare.update { updated } replaceInList(updated) } @@ -177,10 +182,8 @@ class ShareViewModel( // region recipients fun addRecipient(id: String, clazz: String, value: String, instance: String? = null) { viewModelScope.launch(Dispatchers.IO) { - handleResult( - result = repository.addShareRecipient(id, AddRecipientRequest(clazz, value, instance)), - errorId = R.string.share_view_update_error_message - ) { updated -> + val result = repository.addShareRecipient(id, AddRecipientRequest(clazz, value, instance)) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> _activeShare.update { updated } replaceInList(updated) } @@ -189,10 +192,8 @@ class ShareViewModel( fun removeRecipient(id: String, clazz: String, value: String, instance: String? = null) { viewModelScope.launch(Dispatchers.IO) { - handleResult( - result = repository.removeShareRecipient(id, clazz, value, instance), - errorId = R.string.share_view_update_error_message - ) { updated -> + val result = repository.removeShareRecipient(id, clazz, value, instance) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> _activeShare.update { updated } replaceInList(updated) } @@ -203,10 +204,8 @@ class ShareViewModel( // region properties fun updateProperty(id: String, clazz: String, value: String?) { viewModelScope.launch(Dispatchers.IO) { - handleResult( - result = repository.updateShareProperty(id, UpdateSharePropertyRequest(clazz, value)), - errorId = R.string.share_view_update_error_message - ) { updated -> + val result = repository.updateShareProperty(id, UpdateSharePropertyRequest(clazz, value)) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> _activeShare.update { updated } replaceInList(updated) } @@ -217,10 +216,8 @@ class ShareViewModel( // region permissions fun updatePermission(id: String, clazz: String, enabled: Boolean) { viewModelScope.launch(Dispatchers.IO) { - handleResult( - result = repository.updateSharePermission(id, UpdateSharePermissionRequest(clazz, enabled)), - errorId = R.string.share_view_update_error_message - ) { updated -> + val result = repository.updateSharePermission(id, UpdateSharePermissionRequest(clazz, enabled)) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> _activeShare.update { updated } replaceInList(updated) } @@ -231,10 +228,8 @@ class ShareViewModel( // region delete fun deleteShare(id: String) { viewModelScope.launch(Dispatchers.IO) { - handleResult( - result = repository.deleteShare(id), - errorId = R.string.share_view_delete_error_message - ) { + val result = repository.deleteShare(id) + handleResult(result, R.string.share_view_delete_error_message)?.let { _shares.update { current -> current.filterNot { it.id == id } } if (_activeShare.value?.id == id) _activeShare.update { null } } @@ -259,22 +254,15 @@ class ShareViewModel( private fun handleResult( result: NetworkResult, - errorId: Int, - onSuccess: (T) -> Unit - ) { - when (result) { - is NetworkResult.Success -> onSuccess(result.data) + errorId: Int + ): T? { + return when (result) { + is NetworkResult.Success -> result.data is NetworkResult.ServerError, - is NetworkResult.NetworkException -> _errorMessageId.update { errorId } - } - } - - private fun launchWithLoading(block: suspend () -> Unit) { - viewModelScope.launch(Dispatchers.IO) { - _loading.update { true } - _errorMessageId.update { null } - block() - _loading.update { false } + is NetworkResult.NetworkException -> { + _errorMessageId.update { errorId } + null + } } } // endregion diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt index 8f7fcf94..f6a63048 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -276,7 +276,7 @@ class MockShareRepository : ShareRepository { return NetworkResult.Success(filtered) } - override suspend fun createShare(): NetworkResult { + override suspend fun createDraftShare(): NetworkResult { val share = buildShare( id = "mock-share-${System.currentTimeMillis()}", sources = emptyList(), diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index e5574bf7..20ceac7c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -52,7 +52,7 @@ class ShareRemoteRepository( } } - override suspend fun createShare(): NetworkResult = + override suspend fun createDraftShare(): NetworkResult = client.executeRequest( endpoint = SHARE_ENDPOINT, method = HttpMethod.POST, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt index 302304bf..d10524b8 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt @@ -25,7 +25,7 @@ interface ShareRepository { offset: Int ): NetworkResult> - suspend fun createShare(): NetworkResult + suspend fun createDraftShare(): NetworkResult suspend fun fetchShare( id: String, From fa9906abdd6bc4f6975e75bec66c8f35b114e1b6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 28 May 2026 16:02:29 +0300 Subject: [PATCH 23/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 8fbe1c72..e55c7be8 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -20,8 +20,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState @@ -192,6 +194,9 @@ private fun AddOrEditShareBottomSheet( ModalBottomSheet( onDismissRequest = { + if (share.shareState == ShareState.DRAFT) { + viewModel.deleteShare(share.id) + } viewModel.setActiveShare(null) }, sheetState = sheetState, @@ -453,16 +458,30 @@ private fun CollapsibleSettingsSection( } @Composable -private fun SettingsSwitchRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { +private fun SettingsSwitchRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { Row( modifier = Modifier .fillMaxWidth() - .height(48.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically ) { - Text(text = label, style = MaterialTheme.typography.bodyLarge) - Switch(checked = checked, onCheckedChange = onCheckedChange) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) } } From ac501d7bed896085dde623cff7af286da2878b61 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 12:40:08 +0300 Subject: [PATCH 24/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index e55c7be8..2b00526b 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -191,6 +191,8 @@ private fun AddOrEditShareBottomSheet( val categories = remember { ShareCategory.entries.toList() } var selectedCategory by remember { mutableStateOf(categories.first()) } var showAdvancedSettings by remember { mutableStateOf(false) } + var expandedCategories by remember { mutableStateOf(setOf()) } + val permissionsByCategory = share.permissions.groupBy { it.category } ModalBottomSheet( onDismissRequest = { @@ -233,22 +235,37 @@ private fun AddOrEditShareBottomSheet( RecipientSearchField(share, viewModel) } - share.permissions.forEach { permission -> - SettingsSwitchRow( - label = permission.displayName, - checked = permission.enabled, - onCheckedChange = { isChecked -> - viewModel.updatePermission(share.id, permission.clazz, isChecked) + // TODO use more better category names + permissionsByCategory.forEach { (category, permissions) -> + CollapsibleSettingsSection( + label = category ?: "", + isExpanded = category in expandedCategories, + onToggle = { + expandedCategories = if (category in expandedCategories) { + expandedCategories - category + } else { + expandedCategories + category + } + }, + ) { + permissions.forEach { permission -> + SettingsSwitchRow( + label = permission.displayName, + checked = permission.enabled, + onCheckedChange = { isChecked -> + viewModel.updatePermission(share.id, permission.clazz, isChecked) + } + ) } - ) + } } if (share.properties.isNotEmpty()) { CollapsibleSettingsSection( + label = stringResource(R.string.share_view_advanced_settings), isExpanded = showAdvancedSettings, onToggle = { showAdvancedSettings = !showAdvancedSettings } ) { - // Sort by server-defined priority share.properties.sortedBy { it.priority }.forEach { property -> DynamicPropertyField(share.id, property, viewModel) } @@ -424,6 +441,7 @@ private fun DynamicPropertyField(shareId: String, property: Property, viewModel: @Composable private fun CollapsibleSettingsSection( + label: String, isExpanded: Boolean, onToggle: () -> Unit, content: @Composable () -> Unit @@ -438,7 +456,7 @@ private fun CollapsibleSettingsSection( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = stringResource(R.string.share_view_advanced_settings), + text = label, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) @@ -450,9 +468,7 @@ private fun CollapsibleSettingsSection( } AnimatedVisibility(visible = isExpanded) { - Column { - content() - } + Column { content() } } } } From 5090cadf52d906260f2f99f3a19fca32338cb482 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 12:46:17 +0300 Subject: [PATCH 25/95] wip Signed-off-by: alperozturk96 --- ui/build.gradle | 4 ++ .../android/common/ui/share/ShareView.kt | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/ui/build.gradle b/ui/build.gradle index 521a554b..cc6fecfe 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -56,6 +56,10 @@ dependencies { implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-core") + implementation("io.coil-kt.coil3:coil-compose:3.4.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") + implementation("io.coil-kt.coil3:coil-svg:3.4.0") + implementation("com.vanniktech:ui:0.10.0") implementation 'androidx.core:core-ktx:1.19.0' diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 2b00526b..7082837f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -81,10 +82,16 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil3.ImageLoader +import coil3.compose.AsyncImage +import coil3.compose.setSingletonImageLoaderFactory +import coil3.request.ImageRequest +import coil3.svg.SvgDecoder import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.component.ContentUnavailableView import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient +import com.nextcloud.android.common.ui.share.model.api.icon.Icon import com.nextcloud.android.common.ui.share.model.api.property.Property import com.nextcloud.android.common.ui.share.model.api.property.PropertyBoolean import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate @@ -303,6 +310,14 @@ private fun RecipientSearchField( selected = true, onClick = { }, label = { Text(recipient.displayName) }, + leadingIcon = { + recipient.icon?.let { + RecipientIcon( + icon = it, + modifier = Modifier.size(18.dp) + ) + } + }, trailingIcon = { IconButton( onClick = { @@ -364,6 +379,14 @@ private fun RecipientSearchField( } else { results.forEach { recipient -> DropdownMenuItem( + leadingIcon = { + recipient.icon?.let { + RecipientIcon( + icon = it, + modifier = Modifier.size(20.dp) + ) + } + }, text = { Text(recipient.displayName) }, onClick = { viewModel.addRecipient(share.id, recipient.clazz, recipient.value) @@ -379,6 +402,23 @@ private fun RecipientSearchField( } } +@Composable +private fun RecipientIcon(icon: Icon, modifier: Modifier = Modifier) { + val isDark = isSystemInDarkTheme() + val url = if (isDark) icon.dark ?: icon.light else icon.light ?: icon.dark + + if (url != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(url) + .decoderFactory(SvgDecoder.Factory()) + .build(), + contentDescription = null, + modifier = modifier + ) + } +} + @Composable private fun DynamicPropertyField(shareId: String, property: Property, viewModel: ShareViewModel) { when (property) { @@ -628,6 +668,12 @@ fun ComposeView.setupUnifiedShare(sourceId: String, credentials: ServerCredentia val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)) setContent { + setSingletonImageLoaderFactory { context -> + ImageLoader.Builder(context) + .components { add(SvgDecoder.Factory()) } + .build() + } + MaterialTheme( colorScheme = colorScheme, content = { From 4d30792490a21bb476555165f4fa537d6755ec25 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 13:40:44 +0300 Subject: [PATCH 26/95] wip Signed-off-by: alperozturk96 --- .../java/com/nextcloud/android/common/ui/share/ShareView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 7082837f..4c5ea5ff 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -414,7 +414,7 @@ private fun RecipientIcon(icon: Icon, modifier: Modifier = Modifier) { .decoderFactory(SvgDecoder.Factory()) .build(), contentDescription = null, - modifier = modifier + modifier = modifier, ) } } From 307c2187fad818814786180479be2199e54d0dff Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 15:27:41 +0300 Subject: [PATCH 27/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 48 +++++++++---------- .../api/capabilities/SharingCapabilities.kt | 39 +++++++++++++++ 2 files changed, 62 insertions(+), 25 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 4c5ea5ff..0a948c11 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -7,7 +7,6 @@ package com.nextcloud.android.common.ui.share -import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -80,7 +79,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.ImageLoader import coil3.compose.AsyncImage @@ -91,6 +89,7 @@ import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.component.ContentUnavailableView import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient +import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities import com.nextcloud.android.common.ui.share.model.api.icon.Icon import com.nextcloud.android.common.ui.share.model.api.property.Property import com.nextcloud.android.common.ui.share.model.api.property.PropertyBoolean @@ -102,12 +101,12 @@ import com.nextcloud.android.common.ui.share.model.api.property.priority import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.model.ui.ShareCategory -import com.nextcloud.android.common.ui.share.repository.MockShareRepository import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json @Composable -private fun ShareView(sourceId: String, viewModel: ShareViewModel) { +private fun ShareView(sourceId: String, sharingCapabilities: SharingCapabilities, viewModel: ShareViewModel) { val errorMessageId by viewModel.errorMessageId.collectAsState() val shares by viewModel.shares.collectAsState() val activeShare by viewModel.activeShare.collectAsState() @@ -181,6 +180,7 @@ private fun ShareView(sourceId: String, viewModel: ShareViewModel) { activeShare?.let { AddOrEditShareBottomSheet( share = it, + sharingCapabilities = sharingCapabilities, viewModel = viewModel ) } @@ -190,6 +190,7 @@ private fun ShareView(sourceId: String, viewModel: ShareViewModel) { @Composable private fun AddOrEditShareBottomSheet( share: Share, + sharingCapabilities: SharingCapabilities, viewModel: ShareViewModel ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -200,6 +201,11 @@ private fun AddOrEditShareBottomSheet( var showAdvancedSettings by remember { mutableStateOf(false) } var expandedCategories by remember { mutableStateOf(setOf()) } val permissionsByCategory = share.permissions.groupBy { it.category } + val categoryLabelMap = remember(sharingCapabilities) { + sharingCapabilities.permissionCategories.associate { + it.class_field to it.displayName + } + } ModalBottomSheet( onDismissRequest = { @@ -242,11 +248,11 @@ private fun AddOrEditShareBottomSheet( RecipientSearchField(share, viewModel) } - // TODO use more better category names permissionsByCategory.forEach { (category, permissions) -> CollapsibleSettingsSection( - label = category ?: "", - isExpanded = category in expandedCategories, + label = category + ?.let { categoryLabelMap[it] } ?: permissions.first().displayName, + isExpanded = category in expandedCategories, onToggle = { expandedCategories = if (category in expandedCategories) { expandedCategories - category @@ -637,22 +643,6 @@ private fun UnifiedSharesListItem( ) } -@Preview(name = "UnifiedShareView – light", showBackground = true) -@Composable -private fun PreviewLight() { - PreviewTheme { - ShareView(sourceId = "", viewModel = ShareViewModel(MockShareRepository())) - } -} - -@Preview(name = "UnifiedShareView – dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun PreviewDark() { - PreviewTheme(darkTheme = true) { - ShareView(sourceId = "", viewModel = ShareViewModel(MockShareRepository())) - } -} - @Composable private fun PreviewTheme( darkTheme: Boolean = false, @@ -663,7 +653,15 @@ private fun PreviewTheme( } } -fun ComposeView.setupUnifiedShare(sourceId: String, credentials: ServerCredentials, colorScheme: ColorScheme) { +private val json = Json { ignoreUnknownKeys = true } + +fun ComposeView.setupUnifiedShare( + sourceId: String, + sharingJson: String, + credentials: ServerCredentials, + colorScheme: ColorScheme +) { + val sharingCapabilities = json.decodeFromString(sharingJson) val nextcloudHttpClient = NextcloudHttpClient.create(credentials) val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)) @@ -677,7 +675,7 @@ fun ComposeView.setupUnifiedShare(sourceId: String, credentials: ServerCredentia MaterialTheme( colorScheme = colorScheme, content = { - ShareView(sourceId, viewModel) + ShareView(sourceId, sharingCapabilities, viewModel) } ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt new file mode 100644 index 00000000..2f5f45fa --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.model.api.capabilities + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SharingCapabilities( + @SerialName("api_versions") + val apiVersions: List, + val legacy: Legacy, + @SerialName("permission_categories") + val permissionCategories: List, +) + +@Serializable +data class Legacy( + @SerialName("max_sources") + val maxSources: Long, + @SerialName("max_recipients") + val maxRecipients: Long, +) + +@Serializable +data class PermissionCategory( + @SerialName("class") + val class_field: String, + @SerialName("display_name") + val displayName: String, + val hint: String?, + val icon: String?, + val priority: Long, +) From 98a0065c2ff4a2e767b808f22d1b4f67dfe8c6dc Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 15:43:15 +0300 Subject: [PATCH 28/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 0a948c11..9f7b0376 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -64,6 +64,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -199,13 +200,7 @@ private fun AddOrEditShareBottomSheet( val categories = remember { ShareCategory.entries.toList() } var selectedCategory by remember { mutableStateOf(categories.first()) } var showAdvancedSettings by remember { mutableStateOf(false) } - var expandedCategories by remember { mutableStateOf(setOf()) } - val permissionsByCategory = share.permissions.groupBy { it.category } - val categoryLabelMap = remember(sharingCapabilities) { - sharingCapabilities.permissionCategories.associate { - it.class_field to it.displayName - } - } + var expandedCategories by remember { mutableStateOf(emptySet()) } ModalBottomSheet( onDismissRequest = { @@ -248,30 +243,38 @@ private fun AddOrEditShareBottomSheet( RecipientSearchField(share, viewModel) } - permissionsByCategory.forEach { (category, permissions) -> - CollapsibleSettingsSection( - label = category - ?.let { categoryLabelMap[it] } ?: permissions.first().displayName, - isExpanded = category in expandedCategories, - onToggle = { - expandedCategories = if (category in expandedCategories) { - expandedCategories - category - } else { - expandedCategories + category + sharingCapabilities.permissionCategories + .sortedBy { it.priority } + .forEach { sharingCapability -> + key(sharingCapability.class_field) { + CollapsibleSettingsSection( + label = sharingCapability.displayName, + isExpanded = sharingCapability.displayName in expandedCategories, + onToggle = { + expandedCategories = if (expandedCategories.contains(sharingCapability.displayName)) { + expandedCategories - sharingCapability.displayName + } else { + expandedCategories + sharingCapability.displayName + } + }, + ) { + share.permissions + .filter { permission -> permission.category == sharingCapability.class_field } + .sortedBy { it.displayName } + .forEach { permission -> + key(permission.clazz) { + SettingsSwitchRow( + label = permission.displayName, + checked = permission.enabled, + onCheckedChange = { isChecked -> + viewModel.updatePermission(share.id, permission.clazz, isChecked) + } + ) + } + } } - }, - ) { - permissions.forEach { permission -> - SettingsSwitchRow( - label = permission.displayName, - checked = permission.enabled, - onCheckedChange = { isChecked -> - viewModel.updatePermission(share.id, permission.clazz, isChecked) - } - ) } } - } if (share.properties.isNotEmpty()) { CollapsibleSettingsSection( From 2d3ba0e1d898ecba0b2fc7e078b03689b2f6a976 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 16:12:05 +0300 Subject: [PATCH 29/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareView.kt | 83 +++++++++++++++---- ui/src/main/res/drawable/ic_calendar.xml | 24 ++++++ ui/src/main/res/values/strings.xml | 3 + 3 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 ui/src/main/res/drawable/ic_calendar.xml diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index 9f7b0376..da8999ea 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -35,6 +35,8 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.ColorScheme +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -56,9 +58,11 @@ import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.getSelectedDate +import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -105,6 +109,7 @@ import com.nextcloud.android.common.ui.share.model.ui.ShareCategory import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch import kotlinx.serialization.json.Json +import java.time.format.DateTimeFormatter @Composable private fun ShareView(sourceId: String, sharingCapabilities: SharingCapabilities, viewModel: ShareViewModel) { @@ -469,16 +474,37 @@ private fun DynamicPropertyField(shareId: String, property: Property, viewModel: } is PropertyDate -> { - // TODO: Wrap with a DatePickerDialog. Falling back to string entry for now. + var showDatePicker by remember { mutableStateOf(false) } + var dateValue by remember { mutableStateOf(property.value ?: "") } + OutlinedTextField( - value = property.value ?: "", - onValueChange = { viewModel.updateProperty(shareId, property.clazz, it) }, - label = { Text(property.displayName + " (YYYY-MM-DD)") }, + value = dateValue, + onValueChange = { }, + label = { Text(property.displayName + " (MM-dd-yyyy)") }, modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), - singleLine = true + .padding(vertical = 4.dp) + .clickable { showDatePicker = true }, + trailingIcon = { + IconButton(onClick = { showDatePicker = true }) { + Icon( + painter = painterResource(R.drawable.ic_calendar), + contentDescription = "Pick date" + ) + } + }, + enabled = true, + readOnly = true ) + + if (showDatePicker) { + DatePickerModal(onDateSelected = { + dateValue = it ?: "" + viewModel.updateProperty(shareId, property.clazz, dateValue) + }, onDismiss = { + showDatePicker = false + }) + } } is PropertyEnum -> { @@ -488,6 +514,37 @@ private fun DynamicPropertyField(shareId: String, property: Property, viewModel: } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DatePickerModal( + onDateSelected: (String?) -> Unit, + onDismiss: () -> Unit +) { + val datePickerState = rememberDatePickerState() + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + val date = datePickerState.getSelectedDate() + val formatter = DateTimeFormatter.ofPattern("MM-dd-yyyy") + val formatted = date?.format(formatter) + onDateSelected(formatted) + onDismiss() + }) { + Text(stringResource(R.string.common_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_cancel)) + } + } + ) { + DatePicker(state = datePickerState) + } +} + @Composable private fun CollapsibleSettingsSection( label: String, @@ -646,16 +703,6 @@ private fun UnifiedSharesListItem( ) } -@Composable -private fun PreviewTheme( - darkTheme: Boolean = false, - content: @Composable () -> Unit -) { - MaterialTheme { - Surface(content = content) - } -} - private val json = Json { ignoreUnknownKeys = true } fun ComposeView.setupUnifiedShare( @@ -664,7 +711,7 @@ fun ComposeView.setupUnifiedShare( credentials: ServerCredentials, colorScheme: ColorScheme ) { - val sharingCapabilities = json.decodeFromString(sharingJson) + val sharingCapabilities = json.decodeFromString(sharingJson) val nextcloudHttpClient = NextcloudHttpClient.create(credentials) val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)) diff --git a/ui/src/main/res/drawable/ic_calendar.xml b/ui/src/main/res/drawable/ic_calendar.xml new file mode 100644 index 00000000..9e205862 --- /dev/null +++ b/ui/src/main/res/drawable/ic_calendar.xml @@ -0,0 +1,24 @@ + + + + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 0c60cec0..f9272a52 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -6,6 +6,9 @@ --> + OK + Cancel + Create a new share Share %s From b6b37cdb2a6f47e7f1599fc138a45dc27ea33220 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 16:12:30 +0300 Subject: [PATCH 30/95] wip Signed-off-by: alperozturk96 --- .../java/com/nextcloud/android/common/ui/share/ShareView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt index da8999ea..a43b2e61 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -493,7 +493,7 @@ private fun DynamicPropertyField(shareId: String, property: Property, viewModel: ) } }, - enabled = true, + enabled = false, readOnly = true ) From 90c6d821e4d4aa2958900ea5d35afede2b467be6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 08:48:43 +0300 Subject: [PATCH 31/95] wip Signed-off-by: alperozturk96 --- .../ui/share/{ShareView.kt => ShareScreen.kt} | 392 +----------------- .../component/CollapsibleShareSection.kt | 60 +++ .../common/ui/share/component/PropertyView.kt | 78 ++++ .../share/component/RecipientSearchField.kt | 184 ++++++++ .../ui/share/component/ShareDateFormatter.kt | 31 ++ .../ui/share/component/ShareDatePicker.kt | 99 +++++ .../common/ui/share/component/ShareSwitch.kt | 50 +++ .../common/ui/share/model/ui/ShareItemType.kt | 25 ++ 8 files changed, 547 insertions(+), 372 deletions(-) rename ui/src/main/java/com/nextcloud/android/common/ui/share/{ShareView.kt => ShareScreen.kt} (50%) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/PropertyView.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDateFormatter.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDatePicker.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemType.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt similarity index 50% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index a43b2e61..77583f21 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -1,68 +1,46 @@ /* - * Nextcloud Android Common Library + * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.android.common.ui.share -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.ColorScheme -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuAnchorType -import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.InputChip import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.getSelectedDate -import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -83,36 +61,30 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import coil3.ImageLoader -import coil3.compose.AsyncImage import coil3.compose.setSingletonImageLoaderFactory -import coil3.request.ImageRequest import coil3.svg.SvgDecoder import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.component.ContentUnavailableView import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient +import com.nextcloud.android.common.ui.share.component.CollapsibleShareSection +import com.nextcloud.android.common.ui.share.component.PropertyView +import com.nextcloud.android.common.ui.share.component.RecipientSearchField +import com.nextcloud.android.common.ui.share.component.ShareSwitch import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities -import com.nextcloud.android.common.ui.share.model.api.icon.Icon -import com.nextcloud.android.common.ui.share.model.api.property.Property -import com.nextcloud.android.common.ui.share.model.api.property.PropertyBoolean -import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate -import com.nextcloud.android.common.ui.share.model.api.property.PropertyEnum -import com.nextcloud.android.common.ui.share.model.api.property.PropertyPassword -import com.nextcloud.android.common.ui.share.model.api.property.PropertyString import com.nextcloud.android.common.ui.share.model.api.property.priority import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.model.ui.ShareCategory +import com.nextcloud.android.common.ui.share.model.ui.ShareItemType import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import java.time.format.DateTimeFormatter @Composable -private fun ShareView(sourceId: String, sharingCapabilities: SharingCapabilities, viewModel: ShareViewModel) { +private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabilities, viewModel: ShareViewModel) { val errorMessageId by viewModel.errorMessageId.collectAsState() val shares by viewModel.shares.collectAsState() val activeShare by viewModel.activeShare.collectAsState() @@ -160,16 +132,16 @@ private fun ShareView(sourceId: String, sharingCapabilities: SharingCapabilities ) { itemsIndexed(filteredShares) { index, share -> val type = when (index) { - 0 -> UnifiedSharesListItemType.Top - shares.lastIndex -> UnifiedSharesListItemType.Bottom - else -> UnifiedSharesListItemType.Mid + 0 -> ShareItemType.Top + shares.lastIndex -> ShareItemType.Bottom + else -> ShareItemType.Mid } if (index == 0) { Spacer(modifier = Modifier.height(16.dp)) } - UnifiedSharesListItem( + ShareItem( share = share, type = type, onSelectShare = { selected -> @@ -252,7 +224,7 @@ private fun AddOrEditShareBottomSheet( .sortedBy { it.priority } .forEach { sharingCapability -> key(sharingCapability.class_field) { - CollapsibleSettingsSection( + CollapsibleShareSection( label = sharingCapability.displayName, isExpanded = sharingCapability.displayName in expandedCategories, onToggle = { @@ -268,7 +240,7 @@ private fun AddOrEditShareBottomSheet( .sortedBy { it.displayName } .forEach { permission -> key(permission.clazz) { - SettingsSwitchRow( + ShareSwitch( label = permission.displayName, checked = permission.enabled, onCheckedChange = { isChecked -> @@ -282,13 +254,13 @@ private fun AddOrEditShareBottomSheet( } if (share.properties.isNotEmpty()) { - CollapsibleSettingsSection( + CollapsibleShareSection( label = stringResource(R.string.share_view_advanced_settings), isExpanded = showAdvancedSettings, onToggle = { showAdvancedSettings = !showAdvancedSettings } ) { share.properties.sortedBy { it.priority }.forEach { property -> - DynamicPropertyField(share.id, property, viewModel) + PropertyView(share.id, property, viewModel) } } } @@ -296,334 +268,10 @@ private fun AddOrEditShareBottomSheet( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun RecipientSearchField( - share: Share, - viewModel: ShareViewModel -) { - var query by remember { mutableStateOf("") } - var expanded by remember { mutableStateOf(false) } - val results by viewModel.recipientSearchResults.collectAsState() - val chipScrollState = rememberScrollState() - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (share.recipients.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(chipScrollState), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - share.recipients.forEach { recipient -> - InputChip( - selected = true, - onClick = { }, - label = { Text(recipient.displayName) }, - leadingIcon = { - recipient.icon?.let { - RecipientIcon( - icon = it, - modifier = Modifier.size(18.dp) - ) - } - }, - trailingIcon = { - IconButton( - onClick = { - viewModel.removeRecipient( - id = share.id, - clazz = recipient.clazz, - value = recipient.value, - instance = recipient.instance - ) - }, - modifier = Modifier.size(16.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "remove recipient" - ) - } - } - ) - } - } - } - - ExposedDropdownMenuBox( - expanded = expanded && query.isNotBlank(), - onExpandedChange = { expanded = it } - ) { - OutlinedTextField( - value = query, - onValueChange = { - query = it - expanded = true - viewModel.onSearchQueryChanged(it) - }, - label = { Text(stringResource(R.string.share_view_invited_category_label)) }, - modifier = Modifier - .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true) - .fillMaxWidth(), - singleLine = true - ) - - if (query.isNotBlank()) { - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - if (results.isEmpty()) { - DropdownMenuItem( - text = { - Text( - text = stringResource(R.string.share_view_recipient_search_field_empty_result), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - onClick = {}, - enabled = false - ) - } else { - results.forEach { recipient -> - DropdownMenuItem( - leadingIcon = { - recipient.icon?.let { - RecipientIcon( - icon = it, - modifier = Modifier.size(20.dp) - ) - } - }, - text = { Text(recipient.displayName) }, - onClick = { - viewModel.addRecipient(share.id, recipient.clazz, recipient.value) - query = "" - expanded = false - } - ) - } - } - } - } - } - } -} - -@Composable -private fun RecipientIcon(icon: Icon, modifier: Modifier = Modifier) { - val isDark = isSystemInDarkTheme() - val url = if (isDark) icon.dark ?: icon.light else icon.light ?: icon.dark - - if (url != null) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(url) - .decoderFactory(SvgDecoder.Factory()) - .build(), - contentDescription = null, - modifier = modifier, - ) - } -} - -@Composable -private fun DynamicPropertyField(shareId: String, property: Property, viewModel: ShareViewModel) { - when (property) { - is PropertyBoolean -> { - SettingsSwitchRow( - label = property.displayName, - checked = property.value == "true", - onCheckedChange = { isChecked -> - viewModel.updateProperty(shareId, property.clazz, isChecked.toString()) - } - ) - } - - is PropertyString -> { - OutlinedTextField( - value = property.value ?: "", - onValueChange = { viewModel.updateProperty(shareId, property.clazz, it) }, - label = { Text(property.displayName) }, - placeholder = property.hint?.let { { Text(it) } }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - singleLine = true - ) - } - - is PropertyPassword -> { - OutlinedTextField( - value = property.value ?: "", - onValueChange = { viewModel.updateProperty(shareId, property.clazz, it) }, - label = { Text(property.displayName) }, - placeholder = property.hint?.let { { Text(it) } }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - singleLine = true - ) - } - - is PropertyDate -> { - var showDatePicker by remember { mutableStateOf(false) } - var dateValue by remember { mutableStateOf(property.value ?: "") } - - OutlinedTextField( - value = dateValue, - onValueChange = { }, - label = { Text(property.displayName + " (MM-dd-yyyy)") }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable { showDatePicker = true }, - trailingIcon = { - IconButton(onClick = { showDatePicker = true }) { - Icon( - painter = painterResource(R.drawable.ic_calendar), - contentDescription = "Pick date" - ) - } - }, - enabled = false, - readOnly = true - ) - - if (showDatePicker) { - DatePickerModal(onDateSelected = { - dateValue = it ?: "" - viewModel.updateProperty(shareId, property.clazz, dateValue) - }, onDismiss = { - showDatePicker = false - }) - } - } - - is PropertyEnum -> { - // TODO: Implement ExposedDropdownMenuBox using property.validValues - Text(text = "Enum Property: ${property.displayName} (Under Construction)", color = Color.Gray) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DatePickerModal( - onDateSelected: (String?) -> Unit, - onDismiss: () -> Unit -) { - val datePickerState = rememberDatePickerState() - - DatePickerDialog( - onDismissRequest = onDismiss, - confirmButton = { - TextButton(onClick = { - val date = datePickerState.getSelectedDate() - val formatter = DateTimeFormatter.ofPattern("MM-dd-yyyy") - val formatted = date?.format(formatter) - onDateSelected(formatted) - onDismiss() - }) { - Text(stringResource(R.string.common_ok)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.common_cancel)) - } - } - ) { - DatePicker(state = datePickerState) - } -} - -@Composable -private fun CollapsibleSettingsSection( - label: String, - isExpanded: Boolean, - onToggle: () -> Unit, - content: @Composable () -> Unit -) { - Column(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onToggle() } - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - Icon( - imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - } - - AnimatedVisibility(visible = isExpanded) { - Column { content() } - } - } -} - -@Composable -private fun SettingsSwitchRow( - label: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) - - Spacer(modifier = Modifier.width(16.dp)) - - Switch( - checked = checked, - onCheckedChange = onCheckedChange - ) - } -} - -enum class UnifiedSharesListItemType { - Top, Mid, Bottom; - - @Composable - fun getShape(): RoundedCornerShape { - return when (this) { - Top -> RoundedCornerShape(12.dp, 12.dp, 4.dp, 4.dp) - Mid -> RoundedCornerShape(4.dp, 4.dp, 4.dp, 4.dp) - Bottom -> RoundedCornerShape(4.dp, 4.dp, 12.dp, 12.dp) - } - } -} - @Composable -private fun UnifiedSharesListItem( +private fun ShareItem( share: Share, - type: UnifiedSharesListItemType, + type: ShareItemType, onSelectShare: (Share) -> Unit, onDeleteShare: (Share) -> Unit, onSendEmail: (Share) -> Unit @@ -725,7 +373,7 @@ fun ComposeView.setupUnifiedShare( MaterialTheme( colorScheme = colorScheme, content = { - ShareView(sourceId, sharingCapabilities, viewModel) + ShareScreen(sourceId, sharingCapabilities, viewModel) } ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt new file mode 100644 index 00000000..eaabb698 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt @@ -0,0 +1,60 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun CollapsibleShareSection( + label: String, + isExpanded: Boolean, + onToggle: () -> Unit, + content: @Composable () -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle() } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + + AnimatedVisibility(visible = isExpanded) { + Column { content() } + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/PropertyView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/PropertyView.kt new file mode 100644 index 00000000..aa8b568e --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/PropertyView.kt @@ -0,0 +1,78 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.share.ShareViewModel +import com.nextcloud.android.common.ui.share.model.api.property.Property +import com.nextcloud.android.common.ui.share.model.api.property.PropertyBoolean +import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate +import com.nextcloud.android.common.ui.share.model.api.property.PropertyEnum +import com.nextcloud.android.common.ui.share.model.api.property.PropertyPassword +import com.nextcloud.android.common.ui.share.model.api.property.PropertyString + +@Composable +fun PropertyView(shareId: String, property: Property, viewModel: ShareViewModel) { + when (property) { + is PropertyBoolean -> { + ShareSwitch( + label = property.displayName, + checked = property.value == "true", + onCheckedChange = { isChecked -> + viewModel.updateProperty(shareId, property.clazz, isChecked.toString()) + } + ) + } + + is PropertyString -> { + OutlinedTextField( + value = property.value ?: "", + onValueChange = { viewModel.updateProperty(shareId, property.clazz, it) }, + label = { Text(property.displayName) }, + placeholder = property.hint?.let { { Text(it) } }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + singleLine = true + ) + } + + is PropertyPassword -> { + OutlinedTextField( + value = property.value ?: "", + onValueChange = { viewModel.updateProperty(shareId, property.clazz, it) }, + label = { Text(property.displayName) }, + placeholder = property.hint?.let { { Text(it) } }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + singleLine = true + ) + } + + is PropertyDate -> { + ShareDatePicker(property, onDateSelected = { dateValue -> + viewModel.updateProperty(shareId, property.clazz, dateValue) + }) + } + + is PropertyEnum -> { + // TODO: Implement ExposedDropdownMenuBox using property.validValues + Text(text = "Enum Property: ${property.displayName} (Under Construction)", color = Color.Gray) + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt new file mode 100644 index 00000000..05d02d4c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt @@ -0,0 +1,184 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.svg.SvgDecoder +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.ShareViewModel +import com.nextcloud.android.common.ui.share.model.api.icon.Icon +import com.nextcloud.android.common.ui.share.model.api.share.Share + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecipientSearchField( + share: Share, + viewModel: ShareViewModel +) { + var query by remember { mutableStateOf("") } + var expanded by remember { mutableStateOf(false) } + val results by viewModel.recipientSearchResults.collectAsState() + val chipScrollState = rememberScrollState() + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (share.recipients.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(chipScrollState), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + share.recipients.forEach { recipient -> + InputChip( + selected = true, + onClick = { }, + label = { Text(recipient.displayName) }, + leadingIcon = { + recipient.icon?.let { + RecipientIcon( + icon = it, + modifier = Modifier.size(18.dp) + ) + } + }, + trailingIcon = { + IconButton( + onClick = { + viewModel.removeRecipient( + id = share.id, + clazz = recipient.clazz, + value = recipient.value, + instance = recipient.instance + ) + }, + modifier = Modifier.size(16.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "remove recipient" + ) + } + } + ) + } + } + } + + ExposedDropdownMenuBox( + expanded = expanded && query.isNotBlank(), + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = query, + onValueChange = { + query = it + expanded = true + viewModel.onSearchQueryChanged(it) + }, + label = { Text(stringResource(R.string.share_view_invited_category_label)) }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true) + .fillMaxWidth(), + singleLine = true + ) + + if (query.isNotBlank()) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + if (results.isEmpty()) { + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.share_view_recipient_search_field_empty_result), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = {}, + enabled = false + ) + } else { + results.forEach { recipient -> + DropdownMenuItem( + leadingIcon = { + recipient.icon?.let { + RecipientIcon( + icon = it, + modifier = Modifier.size(20.dp) + ) + } + }, + text = { Text(recipient.displayName) }, + onClick = { + viewModel.addRecipient(share.id, recipient.clazz, recipient.value) + query = "" + expanded = false + } + ) + } + } + } + } + } + } +} + +@Composable +private fun RecipientIcon(icon: Icon, modifier: Modifier = Modifier) { + val isDark = isSystemInDarkTheme() + val url = if (isDark) icon.dark ?: icon.light else icon.light ?: icon.dark + + if (url != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(url) + .decoderFactory(SvgDecoder.Factory()) + .build(), + contentDescription = null, + modifier = modifier, + ) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDateFormatter.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDateFormatter.kt new file mode 100644 index 00000000..44d1aace --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDateFormatter.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.material3.DatePickerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.getSelectedDate +import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate +import java.time.format.DateTimeFormatter + +class ShareDateFormatter { + companion object { + private const val PATTERN = "MM-dd-yyyy" + } + + fun getDisplayName(property: PropertyDate): String { + return property.displayName + " ($PATTERN)" + } + + @OptIn(ExperimentalMaterial3Api::class) + fun formatDate(datePickerState: DatePickerState): String? { + val date = datePickerState.getSelectedDate() + val formatter = DateTimeFormatter.ofPattern(PATTERN) + return date?.format(formatter) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDatePicker.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDatePicker.kt new file mode 100644 index 00000000..9b02e7eb --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDatePicker.kt @@ -0,0 +1,99 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.getSelectedDate +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate +import java.time.format.DateTimeFormatter + +@Composable +fun ShareDatePicker(property: PropertyDate, onDateSelected: (String) -> Unit) { + val formatter = ShareDateFormatter() + var showDatePicker by remember { mutableStateOf(false) } + var dateValue by remember { mutableStateOf(property.value ?: "") } + + OutlinedTextField( + value = dateValue, + onValueChange = { }, + label = { Text(formatter.getDisplayName(property)) }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { showDatePicker = true }, + trailingIcon = { + IconButton(onClick = { showDatePicker = true }) { + Icon( + painter = painterResource(R.drawable.ic_calendar), + contentDescription = "Pick date" + ) + } + }, + enabled = false, + readOnly = true + ) + + if (showDatePicker) { + DatePickerModal(formatter, onDateSelected = { + dateValue = it ?: "" + onDateSelected(dateValue) + }, onDismiss = { + showDatePicker = false + }) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DatePickerModal( + formatter: ShareDateFormatter, + onDateSelected: (String?) -> Unit, + onDismiss: () -> Unit +) { + val datePickerState = rememberDatePickerState() + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + onDateSelected(formatter.formatDate(datePickerState)) + onDismiss() + }) { + Text(stringResource(R.string.common_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_cancel)) + } + } + ) { + DatePicker(state = datePickerState) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt new file mode 100644 index 00000000..61499f2a --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ShareSwitch( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemType.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemType.kt new file mode 100644 index 00000000..20205442 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemType.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.model.ui + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp + +enum class ShareItemType { + Top, Mid, Bottom; + + @Composable + fun getShape(): RoundedCornerShape { + return when (this) { + Top -> RoundedCornerShape(12.dp, 12.dp, 4.dp, 4.dp) + Mid -> RoundedCornerShape(4.dp, 4.dp, 4.dp, 4.dp) + Bottom -> RoundedCornerShape(4.dp, 4.dp, 12.dp, 12.dp) + } + } +} From c0e601eaedcaeda679952fadd53ee7371005ce95 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 08:49:41 +0300 Subject: [PATCH 32/95] wip Signed-off-by: alperozturk96 --- .../java/com/nextcloud/android/common/ui/share/ShareScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index 77583f21..54215f71 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -353,7 +353,7 @@ private fun ShareItem( private val json = Json { ignoreUnknownKeys = true } -fun ComposeView.setupUnifiedShare( +fun ComposeView.initShareScreen( sourceId: String, sharingJson: String, credentials: ServerCredentials, From d6b595f9e9f88661ef04166d44c6ca174766d53a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 08:59:44 +0300 Subject: [PATCH 33/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 122 +--------- .../component/AddOrEditShareBottomSheet.kt | 195 +++++++++++++++ .../share/component/RecipientSearchField.kt | 224 +++++++++++------- .../SharePropertyView.kt} | 6 +- .../datepicker}/ShareDatePicker.kt | 7 +- .../datepicker/util}/ShareDateFormatter.kt | 4 +- 6 files changed, 339 insertions(+), 219 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt rename ui/src/main/java/com/nextcloud/android/common/ui/share/component/{PropertyView.kt => property/SharePropertyView.kt} (90%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/component/{ => property/datepicker}/ShareDatePicker.kt (92%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/component/{ => property/datepicker/util}/ShareDateFormatter.kt (91%) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index 54215f71..5b040368 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -9,23 +9,18 @@ package com.nextcloud.android.common.ui.share import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.ColorScheme import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -33,20 +28,14 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -69,15 +58,10 @@ import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.component.ContentUnavailableView import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient -import com.nextcloud.android.common.ui.share.component.CollapsibleShareSection -import com.nextcloud.android.common.ui.share.component.PropertyView -import com.nextcloud.android.common.ui.share.component.RecipientSearchField -import com.nextcloud.android.common.ui.share.component.ShareSwitch +import com.nextcloud.android.common.ui.share.component.AddOrEditShareBottomSheet import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities -import com.nextcloud.android.common.ui.share.model.api.property.priority import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState -import com.nextcloud.android.common.ui.share.model.ui.ShareCategory import com.nextcloud.android.common.ui.share.model.ui.ShareItemType import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch @@ -164,110 +148,6 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AddOrEditShareBottomSheet( - share: Share, - sharingCapabilities: SharingCapabilities, - viewModel: ShareViewModel -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scrollState = rememberScrollState() - val context = LocalContext.current - val categories = remember { ShareCategory.entries.toList() } - var selectedCategory by remember { mutableStateOf(categories.first()) } - var showAdvancedSettings by remember { mutableStateOf(false) } - var expandedCategories by remember { mutableStateOf(emptySet()) } - - ModalBottomSheet( - onDismissRequest = { - if (share.shareState == ShareState.DRAFT) { - viewModel.deleteShare(share.id) - } - viewModel.setActiveShare(null) - }, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 32.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = share.title(context), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp) - ) - - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - categories.forEachIndexed { index, category -> - SegmentedButton( - selected = selectedCategory == category, - onClick = { selectedCategory = category }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size) - ) { - Text(category.name) - } - } - } - - if (selectedCategory == ShareCategory.Invited) { - RecipientSearchField(share, viewModel) - } - - sharingCapabilities.permissionCategories - .sortedBy { it.priority } - .forEach { sharingCapability -> - key(sharingCapability.class_field) { - CollapsibleShareSection( - label = sharingCapability.displayName, - isExpanded = sharingCapability.displayName in expandedCategories, - onToggle = { - expandedCategories = if (expandedCategories.contains(sharingCapability.displayName)) { - expandedCategories - sharingCapability.displayName - } else { - expandedCategories + sharingCapability.displayName - } - }, - ) { - share.permissions - .filter { permission -> permission.category == sharingCapability.class_field } - .sortedBy { it.displayName } - .forEach { permission -> - key(permission.clazz) { - ShareSwitch( - label = permission.displayName, - checked = permission.enabled, - onCheckedChange = { isChecked -> - viewModel.updatePermission(share.id, permission.clazz, isChecked) - } - ) - } - } - } - } - } - - if (share.properties.isNotEmpty()) { - CollapsibleShareSection( - label = stringResource(R.string.share_view_advanced_settings), - isExpanded = showAdvancedSettings, - onToggle = { showAdvancedSettings = !showAdvancedSettings } - ) { - share.properties.sortedBy { it.priority }.forEach { property -> - PropertyView(share.id, property, viewModel) - } - } - } - } - } -} - @Composable private fun ShareItem( share: Share, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt new file mode 100644 index 00000000..d42ba258 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt @@ -0,0 +1,195 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.ShareViewModel +import com.nextcloud.android.common.ui.share.component.property.SharePropertyView +import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities +import com.nextcloud.android.common.ui.share.model.api.property.priority +import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.ui.ShareCategory + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddOrEditShareBottomSheet( + share: Share, + sharingCapabilities: SharingCapabilities, + viewModel: ShareViewModel +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scrollState = rememberScrollState() + val categories = remember { ShareCategory.entries.toList() } + var selectedCategory by remember { mutableStateOf(categories.first()) } + var showAdvancedSettings by remember { mutableStateOf(false) } + var expandedCategories by remember { mutableStateOf(emptySet()) } + + ModalBottomSheet( + onDismissRequest = { + if (share.shareState == ShareState.DRAFT) { + viewModel.deleteShare(share.id) + } + viewModel.setActiveShare(null) + }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + ShareTitle(share) + + CategorySelector( + categories = categories, + selectedCategory = selectedCategory, + onCategorySelected = { selectedCategory = it } + ) + + if (selectedCategory == ShareCategory.Invited) { + RecipientSearchField(share, viewModel) + } + + PermissionCategories( + share = share, + sharingCapabilities = sharingCapabilities, + expandedCategories = expandedCategories, + onToggleCategory = { categoryName -> + expandedCategories = if (expandedCategories.contains(categoryName)) { + expandedCategories - categoryName + } else { + expandedCategories + categoryName + } + }, + viewModel = viewModel + ) + + if (share.properties.isNotEmpty()) { + AdvancedSettingsSection( + share = share, + isExpanded = showAdvancedSettings, + onToggle = { showAdvancedSettings = !showAdvancedSettings }, + viewModel = viewModel + ) + } + } + } +} + +@Composable +private fun ShareTitle(share: Share) { + val context = LocalContext.current + Text( + text = share.title(context), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) +} + +@Composable +private fun CategorySelector( + categories: List, + selectedCategory: ShareCategory, + onCategorySelected: (ShareCategory) -> Unit +) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + categories.forEachIndexed { index, category -> + SegmentedButton( + selected = selectedCategory == category, + onClick = { onCategorySelected(category) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size) + ) { + Text(category.name) + } + } + } +} + +@Composable +private fun PermissionCategories( + share: Share, + sharingCapabilities: SharingCapabilities, + expandedCategories: Set, + onToggleCategory: (String) -> Unit, + viewModel: ShareViewModel +) { + sharingCapabilities.permissionCategories + .sortedBy { it.priority } + .forEach { sharingCapability -> + key(sharingCapability.class_field) { + CollapsibleShareSection( + label = sharingCapability.displayName, + isExpanded = sharingCapability.displayName in expandedCategories, + onToggle = { onToggleCategory(sharingCapability.displayName) }, + ) { + share.permissions + .filter { permission -> permission.category == sharingCapability.class_field } + .sortedBy { it.displayName } + .forEach { permission -> + key(permission.clazz) { + ShareSwitch( + label = permission.displayName, + checked = permission.enabled, + onCheckedChange = { isChecked -> + viewModel.updatePermission(share.id, permission.clazz, isChecked) + } + ) + } + } + } + } + } +} + +@Composable +private fun AdvancedSettingsSection( + share: Share, + isExpanded: Boolean, + onToggle: () -> Unit, + viewModel: ShareViewModel +) { + CollapsibleShareSection( + label = stringResource(R.string.share_view_advanced_settings), + isExpanded = isExpanded, + onToggle = onToggle + ) { + share.properties.sortedBy { it.priority }.forEach { property -> + SharePropertyView(share.id, property, viewModel) + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt index 05d02d4c..4e14adf4 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt @@ -44,6 +44,7 @@ import coil3.svg.SvgDecoder import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel import com.nextcloud.android.common.ui.share.model.api.icon.Icon +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.share.Share @OptIn(ExperimentalMaterial3Api::class) @@ -62,110 +63,155 @@ fun RecipientSearchField( verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (share.recipients.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(chipScrollState), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - share.recipients.forEach { recipient -> - InputChip( - selected = true, - onClick = { }, - label = { Text(recipient.displayName) }, - leadingIcon = { - recipient.icon?.let { - RecipientIcon( - icon = it, - modifier = Modifier.size(18.dp) - ) - } - }, - trailingIcon = { - IconButton( - onClick = { - viewModel.removeRecipient( - id = share.id, - clazz = recipient.clazz, - value = recipient.value, - instance = recipient.instance - ) - }, - modifier = Modifier.size(16.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "remove recipient" - ) - } - } + RecipientChipRow( + recipients = share.recipients, + chipScrollState = chipScrollState, + onRemove = { recipient -> + viewModel.removeRecipient( + id = share.id, + clazz = recipient.clazz, + value = recipient.value, + instance = recipient.instance ) } - } + ) } - ExposedDropdownMenuBox( - expanded = expanded && query.isNotBlank(), - onExpandedChange = { expanded = it } + RecipientSearchDropdown( + query = query, + expanded = expanded, + onQueryChange = { + query = it + expanded = true + viewModel.onSearchQueryChanged(it) + }, + onExpandedChange = { expanded = it }, + onDismiss = { expanded = false } ) { - OutlinedTextField( - value = query, - onValueChange = { - query = it - expanded = true - viewModel.onSearchQueryChanged(it) - }, - label = { Text(stringResource(R.string.share_view_invited_category_label)) }, - modifier = Modifier - .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true) - .fillMaxWidth(), - singleLine = true + RecipientDropdownContent( + results = results, + onSelect = { recipient -> + viewModel.addRecipient(share.id, recipient.clazz, recipient.value) + query = "" + expanded = false + } ) + } + } +} - if (query.isNotBlank()) { - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - if (results.isEmpty()) { - DropdownMenuItem( - text = { - Text( - text = stringResource(R.string.share_view_recipient_search_field_empty_result), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - onClick = {}, - enabled = false +@Composable +private fun RecipientChipRow( + recipients: List, + chipScrollState: androidx.compose.foundation.ScrollState, + onRemove: (Recipient) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(chipScrollState), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + recipients.forEach { recipient -> + InputChip( + selected = true, + onClick = { }, + label = { Text(recipient.displayName) }, + leadingIcon = { + recipient.icon?.let { + RecipientIcon( + icon = it, + modifier = Modifier.size(18.dp) + ) + } + }, + trailingIcon = { + IconButton( + onClick = { onRemove(recipient) }, + modifier = Modifier.size(16.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "remove recipient" ) - } else { - results.forEach { recipient -> - DropdownMenuItem( - leadingIcon = { - recipient.icon?.let { - RecipientIcon( - icon = it, - modifier = Modifier.size(20.dp) - ) - } - }, - text = { Text(recipient.displayName) }, - onClick = { - viewModel.addRecipient(share.id, recipient.clazz, recipient.value) - query = "" - expanded = false - } - ) - } } } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RecipientSearchDropdown( + query: String, + expanded: Boolean, + onQueryChange: (String) -> Unit, + onExpandedChange: (Boolean) -> Unit, + onDismiss: () -> Unit, + content: @Composable () -> Unit +) { + ExposedDropdownMenuBox( + expanded = expanded && query.isNotBlank(), + onExpandedChange = onExpandedChange + ) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + label = { Text(stringResource(R.string.share_view_invited_category_label)) }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true) + .fillMaxWidth(), + singleLine = true + ) + + if (query.isNotBlank()) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss + ) { + content() } } } } +@Composable +private fun RecipientDropdownContent( + results: List, + onSelect: (Recipient) -> Unit +) { + if (results.isEmpty()) { + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.share_view_recipient_search_field_empty_result), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = {}, + enabled = false + ) + } else { + results.forEach { recipient -> + DropdownMenuItem( + leadingIcon = { + recipient.icon?.let { + RecipientIcon( + icon = it, + modifier = Modifier.size(20.dp) + ) + } + }, + text = { Text(recipient.displayName) }, + onClick = { onSelect(recipient) } + ) + } + } +} + @Composable private fun RecipientIcon(icon: Icon, modifier: Modifier = Modifier) { val isDark = isSystemInDarkTheme() diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/PropertyView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt similarity index 90% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/component/PropertyView.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt index aa8b568e..28059ad2 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/PropertyView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.android.common.ui.share.component +package com.nextcloud.android.common.ui.share.component.property import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -17,6 +17,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.share.ShareViewModel +import com.nextcloud.android.common.ui.share.component.property.datepicker.ShareDatePicker +import com.nextcloud.android.common.ui.share.component.ShareSwitch import com.nextcloud.android.common.ui.share.model.api.property.Property import com.nextcloud.android.common.ui.share.model.api.property.PropertyBoolean import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate @@ -25,7 +27,7 @@ import com.nextcloud.android.common.ui.share.model.api.property.PropertyPassword import com.nextcloud.android.common.ui.share.model.api.property.PropertyString @Composable -fun PropertyView(shareId: String, property: Property, viewModel: ShareViewModel) { +fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewModel) { when (property) { is PropertyBoolean -> { ShareSwitch( diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDatePicker.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt similarity index 92% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDatePicker.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt index 9b02e7eb..1b0325e0 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDatePicker.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt @@ -5,20 +5,18 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.android.common.ui.share.component +package com.nextcloud.android.common.ui.share.component.property.datepicker import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.getSelectedDate import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -30,8 +28,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.component.property.datepicker.util.ShareDateFormatter import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate -import java.time.format.DateTimeFormatter @Composable fun ShareDatePicker(property: PropertyDate, onDateSelected: (String) -> Unit) { @@ -69,7 +67,6 @@ fun ShareDatePicker(property: PropertyDate, onDateSelected: (String) -> Unit) { } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun DatePickerModal( formatter: ShareDateFormatter, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDateFormatter.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt similarity index 91% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDateFormatter.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt index 44d1aace..d24880db 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareDateFormatter.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.android.common.ui.share.component +package com.nextcloud.android.common.ui.share.component.property.datepicker.util import androidx.compose.material3.DatePickerState import androidx.compose.material3.ExperimentalMaterial3Api @@ -28,4 +28,4 @@ class ShareDateFormatter { val formatter = DateTimeFormatter.ofPattern(PATTERN) return date?.format(formatter) } -} +} \ No newline at end of file From e31d35a747242588c46b0f789b68d9e929aa7800 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 09:11:06 +0300 Subject: [PATCH 34/95] wip Signed-off-by: alperozturk96 --- .../property/datepicker/ShareDatePicker.kt | 59 +++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt index 1b0325e0..dbe6e8a1 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt @@ -8,12 +8,12 @@ package com.nextcloud.android.common.ui.share.component.property.datepicker import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -26,7 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.component.property.datepicker.util.ShareDateFormatter import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate @@ -37,25 +37,29 @@ fun ShareDatePicker(property: PropertyDate, onDateSelected: (String) -> Unit) { var showDatePicker by remember { mutableStateOf(false) } var dateValue by remember { mutableStateOf(property.value ?: "") } - OutlinedTextField( - value = dateValue, - onValueChange = { }, - label = { Text(formatter.getDisplayName(property)) }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable { showDatePicker = true }, - trailingIcon = { - IconButton(onClick = { showDatePicker = true }) { + + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = dateValue, + onValueChange = {}, + readOnly = true, + enabled = true, + modifier = Modifier.fillMaxWidth(), + label = { Text(formatter.getDisplayName(property)) }, + trailingIcon = { Icon( painter = painterResource(R.drawable.ic_calendar), - contentDescription = "Pick date" + contentDescription = null ) } - }, - enabled = false, - readOnly = true - ) + ) + + Box( + modifier = Modifier + .matchParentSize() + .clickable { showDatePicker = true } + ) + } if (showDatePicker) { DatePickerModal(formatter, onDateSelected = { @@ -94,3 +98,22 @@ private fun DatePickerModal( DatePicker(state = datePickerState) } } + +@Preview(showBackground = true) +@Composable +private fun ShareDatePickerPreview() { + MaterialTheme { + ShareDatePicker( + property = PropertyDate( + clazz = "expiry", + displayName = "Expiration Date", + priority = 1, + required = false, + value = "2026-12-31", + minDate = "2026-01-01", + maxDate = "2027-12-31" + ), + onDateSelected = {} + ) + } +} From e10776ee356c1088d4176a94eceb743cff204a20 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 09:16:03 +0300 Subject: [PATCH 35/95] wip Signed-off-by: alperozturk96 --- .../common/ui/share/model/api/property/Property.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt index 3dacf64f..2aeb3d8c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt @@ -26,6 +26,15 @@ val Property.priority: Int is PropertyString -> priority } +val Property.clazz: String + get() = when (this) { + is PropertyBoolean -> clazz + is PropertyDate -> clazz + is PropertyEnum -> clazz + is PropertyPassword -> clazz + is PropertyString -> clazz + } + @Serializable @SerialName("boolean") data class PropertyBoolean( @@ -42,7 +51,9 @@ data class PropertyBoolean( val required: Boolean, val value: String? = null -) : Property() +) : Property() { + fun isTrue(): Boolean = (value == "true") +} @Serializable @SerialName("date") From a1894190b41a43ddbbc8bd9162e426dea0987bf4 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 09:52:27 +0300 Subject: [PATCH 36/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 18 ++---- .../android/common/ui/share/ShareViewModel.kt | 17 +++++ .../component/AddOrEditShareBottomSheet.kt | 10 ++- .../component/CollapsibleShareSection.kt | 6 +- .../share/component/RecipientSearchField.kt | 6 +- .../common/ui/share/component/ShareSwitch.kt | 6 +- .../component/property/SharePropertyView.kt | 64 +++++++++++++++---- .../property/datepicker/ShareDatePicker.kt | 6 +- .../datepicker/util/ShareDateFormatter.kt | 6 +- .../api/capabilities/SharingCapabilities.kt | 6 +- .../common/ui/share/model/ui/ShareItemType.kt | 16 ++++- 11 files changed, 114 insertions(+), 47 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index 5b040368..cb271ba2 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -1,8 +1,8 @@ /* - * Nextcloud - Android Client + * Nextcloud Android Common Library * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT */ package com.nextcloud.android.common.ui.share @@ -61,7 +61,6 @@ import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient import com.nextcloud.android.common.ui.share.component.AddOrEditShareBottomSheet import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities import com.nextcloud.android.common.ui.share.model.api.share.Share -import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.model.ui.ShareItemType import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch @@ -75,7 +74,6 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti val context = LocalContext.current val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } - val filteredShares = shares.filter { it.shareState != ShareState.DRAFT } LaunchedEffect(errorMessageId) { errorMessageId?.let { @@ -101,7 +99,7 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti snackbarHost = { SnackbarHost(snackbarHostState) }, containerColor = Color.Transparent ) { paddingValues -> - if (filteredShares.isEmpty()) { + if (shares.isEmpty()) { ContentUnavailableView( iconId = R.drawable.ic_person_add, title = @@ -114,12 +112,8 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - itemsIndexed(filteredShares) { index, share -> - val type = when (index) { - 0 -> ShareItemType.Top - shares.lastIndex -> ShareItemType.Bottom - else -> ShareItemType.Mid - } + itemsIndexed(shares, key = { _, share -> share.id }) { index, share -> + val type = ShareItemType.type(index, shares.lastIndex) if (index == 0) { Spacer(modifier = Modifier.height(16.dp)) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 36f5502c..afc91946 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -53,6 +53,8 @@ class ShareViewModel( private val _errorMessageId = MutableStateFlow(null) val errorMessageId: StateFlow = _errorMessageId + private val pendingProperties = mutableMapOf() + init { fetchShares() initSearchQuery() @@ -211,6 +213,21 @@ class ShareViewModel( } } } + + fun updatePropertyLocally(clazz: String, value: String) { + pendingProperties[clazz] = value + } + + fun commitPendingProperties(shareId: String) { + if (pendingProperties.isEmpty()) return + + viewModelScope.launch(Dispatchers.IO) { + pendingProperties.forEach { (clazz, value) -> + repository.updateShareProperty(shareId, UpdateSharePropertyRequest(clazz, value)) + } + pendingProperties.clear() + } + } // endregion // region permissions diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt index d42ba258..0ca202f1 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt @@ -35,6 +35,7 @@ import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel import com.nextcloud.android.common.ui.share.component.property.SharePropertyView import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities +import com.nextcloud.android.common.ui.share.model.api.property.clazz import com.nextcloud.android.common.ui.share.model.api.property.priority import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState @@ -56,6 +57,7 @@ fun AddOrEditShareBottomSheet( ModalBottomSheet( onDismissRequest = { + viewModel.commitPendingProperties(share.id) if (share.shareState == ShareState.DRAFT) { viewModel.deleteShare(share.id) } @@ -189,7 +191,13 @@ private fun AdvancedSettingsSection( onToggle = onToggle ) { share.properties.sortedBy { it.priority }.forEach { property -> - SharePropertyView(share.id, property, viewModel) + key(property.clazz) { + SharePropertyView( + shareId = share.id, + property = property, + viewModel = viewModel + ) + } } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt index eaabb698..affe1da5 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt @@ -1,8 +1,8 @@ /* - * Nextcloud - Android Client + * Nextcloud Android Common Library * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT */ package com.nextcloud.android.common.ui.share.component diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt index 4e14adf4..9dfd0a6f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt @@ -1,8 +1,8 @@ /* - * Nextcloud - Android Client + * Nextcloud Android Common Library * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT */ package com.nextcloud.android.common.ui.share.component diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt index 61499f2a..8af52f43 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt @@ -1,8 +1,8 @@ /* - * Nextcloud - Android Client + * Nextcloud Android Common Library * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT */ package com.nextcloud.android.common.ui.share.component diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt index 28059ad2..974e381e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt @@ -1,8 +1,8 @@ /* - * Nextcloud - Android Client + * Nextcloud Android Common Library * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT */ package com.nextcloud.android.common.ui.share.component.property @@ -12,6 +12,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -25,37 +30,54 @@ import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate import com.nextcloud.android.common.ui.share.model.api.property.PropertyEnum import com.nextcloud.android.common.ui.share.model.api.property.PropertyPassword import com.nextcloud.android.common.ui.share.model.api.property.PropertyString +import kotlinx.coroutines.delay @Composable fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewModel) { when (property) { is PropertyBoolean -> { + var checkedValue by remember(property.clazz) { mutableStateOf(property.isTrue()) } + ShareSwitch( label = property.displayName, - checked = property.value == "true", + checked = checkedValue, onCheckedChange = { isChecked -> - viewModel.updateProperty(shareId, property.clazz, isChecked.toString()) + checkedValue = isChecked + viewModel.updatePropertyLocally(property.clazz, isChecked.toString()) } ) } is PropertyString -> { + var textValue by remember(property.clazz) { mutableStateOf(property.value ?: "") } + + DebouncedPropertyUpdater( + value = textValue, + originalValue = property.value ?: "", + onDebounceComplete = { viewModel.updatePropertyLocally(property.clazz, it) } + ) + OutlinedTextField( - value = property.value ?: "", - onValueChange = { viewModel.updateProperty(shareId, property.clazz, it) }, + value = textValue, + onValueChange = { textValue = it }, label = { Text(property.displayName) }, - placeholder = property.hint?.let { { Text(it) } }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), singleLine = true ) } is PropertyPassword -> { + var passwordValue by remember(property.clazz) { mutableStateOf(property.value ?: "") } + + DebouncedPropertyUpdater( + value = passwordValue, + originalValue = property.value ?: "", + onDebounceComplete = { viewModel.updatePropertyLocally(property.clazz, it) } + ) + OutlinedTextField( - value = property.value ?: "", - onValueChange = { viewModel.updateProperty(shareId, property.clazz, it) }, + value = passwordValue, + onValueChange = { passwordValue = it }, label = { Text(property.displayName) }, placeholder = property.hint?.let { { Text(it) } }, visualTransformation = PasswordVisualTransformation(), @@ -78,3 +100,19 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM } } } + +@Composable +private fun DebouncedPropertyUpdater( + value: String, + originalValue: String, + delayMillis: Long = 400L, + onDebounceComplete: (String) -> Unit +) { + LaunchedEffect(value) { + if (value != originalValue) { + delay(delayMillis) + onDebounceComplete(value) + } + } +} + diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt index dbe6e8a1..a99fae18 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt @@ -1,8 +1,8 @@ /* - * Nextcloud - Android Client + * Nextcloud Android Common Library * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT */ package com.nextcloud.android.common.ui.share.component.property.datepicker diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt index d24880db..7caca750 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt @@ -1,8 +1,8 @@ /* - * Nextcloud - Android Client + * Nextcloud Android Common Library * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT */ package com.nextcloud.android.common.ui.share.component.property.datepicker.util diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt index 2f5f45fa..545f925c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt @@ -1,8 +1,8 @@ /* - * Nextcloud - Android Client + * Nextcloud Android Common Library * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT */ package com.nextcloud.android.common.ui.share.model.api.capabilities diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemType.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemType.kt index 20205442..f3c08a5f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemType.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemType.kt @@ -1,8 +1,8 @@ /* - * Nextcloud - Android Client + * Nextcloud Android Common Library * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT */ package com.nextcloud.android.common.ui.share.model.ui @@ -22,4 +22,14 @@ enum class ShareItemType { Bottom -> RoundedCornerShape(4.dp, 4.dp, 12.dp, 12.dp) } } + + companion object { + fun type(index: Int, lastIndex: Int): ShareItemType { + return when (index) { + 0 -> Top + lastIndex -> Bottom + else -> Mid + } + } + } } From d51cd8c6e4deafcc54837fd8817523ad76184604 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 10:10:20 +0300 Subject: [PATCH 37/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 71 +++++++++++-------- .../android/common/ui/share/ShareViewModel.kt | 58 +++++++-------- .../component/AddOrEditShareBottomSheet.kt | 30 ++++++-- .../component/DiscardDraftShareDialog.kt | 37 ++++++++++ .../ui/share/model/ui/ShareScreenState.kt | 16 +++++ ui/src/main/res/values/strings.xml | 5 ++ 6 files changed, 149 insertions(+), 68 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/DiscardDraftShareDialog.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareScreenState.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index cb271ba2..924b83f4 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -18,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ColorScheme import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -62,6 +64,7 @@ import com.nextcloud.android.common.ui.share.component.AddOrEditShareBottomSheet import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.ui.ShareItemType +import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -69,7 +72,7 @@ import kotlinx.serialization.json.Json @Composable private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabilities, viewModel: ShareViewModel) { val errorMessageId by viewModel.errorMessageId.collectAsState() - val shares by viewModel.shares.collectAsState() + val screenState by viewModel.state.collectAsState() val activeShare by viewModel.activeShare.collectAsState() val context = LocalContext.current val scope = rememberCoroutineScope() @@ -99,35 +102,47 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti snackbarHost = { SnackbarHost(snackbarHostState) }, containerColor = Color.Transparent ) { paddingValues -> - if (shares.isEmpty()) { - ContentUnavailableView( - iconId = R.drawable.ic_person_add, - title = - stringResource(R.string.share_view_empty_title), - ) - } else { - LazyColumn( - modifier = Modifier - .padding(paddingValues) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - itemsIndexed(shares, key = { _, share -> share.id }) { index, share -> - val type = ShareItemType.type(index, shares.lastIndex) + when (val state = screenState) { + is ShareScreenState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + is ShareScreenState.Empty -> { + ContentUnavailableView( + iconId = R.drawable.ic_person_add, + title = stringResource(R.string.share_view_empty_title), + ) + } + is ShareScreenState.Loaded -> { + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + itemsIndexed(state.shares, key = { _, share -> share.id }) { index, share -> + val type = ShareItemType.type(index, state.shares.lastIndex) - if (index == 0) { - Spacer(modifier = Modifier.height(16.dp)) - } + if (index == 0) { + Spacer(modifier = Modifier.height(16.dp)) + } else { + Spacer(modifier = Modifier.height(2.dp)) + } - ShareItem( - share = share, - type = type, - onSelectShare = { selected -> - viewModel.setActiveShare(selected) - }, - onDeleteShare = { viewModel.deleteShare(it.id) }, - onSendEmail = { } - ) + ShareItem( + share = share, + type = type, + onSelectShare = { selected -> viewModel.setActiveShare(selected) }, + onDeleteShare = { viewModel.deleteShare(it.id) }, + onSendEmail = { } + ) + } } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index afc91946..42298b3e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -20,6 +20,7 @@ import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareProper import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState import com.nextcloud.android.common.ui.share.repository.ShareRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -36,8 +37,8 @@ class ShareViewModel( private val repository: ShareRepository ) : ViewModel() { - private val _shares = MutableStateFlow>(emptyList()) - val shares: StateFlow> = _shares + private val _state = MutableStateFlow(ShareScreenState.Loading) + val state: StateFlow = _state private val _activeShare = MutableStateFlow(null) val activeShare: StateFlow = _activeShare @@ -47,14 +48,14 @@ class ShareViewModel( private val _recipientSearchResults = MutableStateFlow>(emptyList()) val recipientSearchResults: StateFlow> = _recipientSearchResults - private val _loading = MutableStateFlow(false) - val loading: StateFlow = _loading - private val _errorMessageId = MutableStateFlow(null) val errorMessageId: StateFlow = _errorMessageId private val pendingProperties = mutableMapOf() + private val currentShares: List + get() = (_state.value as? ShareScreenState.Loaded)?.shares ?: emptyList() + init { fetchShares() initSearchQuery() @@ -68,9 +69,7 @@ class ShareViewModel( .debounce(300L) .distinctUntilChanged() .filter { it.isNotBlank() } - .collect { query -> - executeSearch(query) - } + .collect { query -> executeSearch(query) } } } @@ -93,21 +92,21 @@ class ShareViewModel( limit: Int = 50 ) { viewModelScope.launch(Dispatchers.IO) { - _loading.update { true } + _state.update { ShareScreenState.Loading } _errorMessageId.update { null } val result = repository.fetchShares(sourceClass, lastShareID, limit) - handleResult(result, R.string.share_view_fetch_error_message)?.let { fetchedShares -> - _shares.update { fetchedShares } + handleResult(result, R.string.share_view_fetch_error_message)?.let { fetched -> + _state.update { + if (fetched.isEmpty()) ShareScreenState.Empty + else ShareScreenState.Loaded(fetched) + } } - - _loading.update { false } } } fun fetchShare(id: String, request: GetShareRequest = GetShareRequest()) { viewModelScope.launch(Dispatchers.IO) { - _loading.update { true } _errorMessageId.update { null } val result = repository.fetchShare(id, request) @@ -115,20 +114,12 @@ class ShareViewModel( _activeShare.update { share } replaceInList(share) } - - _loading.update { false } } } // endregion // region create - /** - * Creates an empty draft [Share] on the server and sets it as the [activeShare]. - * Then sources can be added later. - * - */ suspend fun createDraftShare(): Share? = withContext(Dispatchers.IO) { - _loading.update { true } _errorMessageId.update { null } val result = repository.createDraftShare() @@ -136,13 +127,10 @@ class ShareViewModel( if (draft != null) { _activeShare.update { draft } - _shares.update { current -> listOf(draft) + current } - _loading.update { false } - draft - } else { - _loading.update { false } - null + _state.update { ShareScreenState.Loaded(listOf(draft) + currentShares) } } + + draft } // endregion @@ -247,7 +235,11 @@ class ShareViewModel( viewModelScope.launch(Dispatchers.IO) { val result = repository.deleteShare(id) handleResult(result, R.string.share_view_delete_error_message)?.let { - _shares.update { current -> current.filterNot { it.id == id } } + val remaining = currentShares.filterNot { it.id == id } + _state.update { + if (remaining.isEmpty()) ShareScreenState.Empty + else ShareScreenState.Loaded(remaining) + } if (_activeShare.value?.id == id) _activeShare.update { null } } } @@ -266,13 +258,11 @@ class ShareViewModel( // region private private fun replaceInList(updated: Share) { - _shares.update { current -> current.map { if (it.id == updated.id) updated else it } } + val shares = currentShares.ifEmpty { return } + _state.update { ShareScreenState.Loaded(shares.map { if (it.id == updated.id) updated else it }) } } - private fun handleResult( - result: NetworkResult, - errorId: Int - ): T? { + private fun handleResult(result: NetworkResult, errorId: Int): T? { return when (result) { is NetworkResult.Success -> result.data is NetworkResult.ServerError, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt index 0ca202f1..3a34eff6 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt @@ -1,10 +1,11 @@ /* - * Nextcloud - Android Client + * Nextcloud Android Common Library * - * SPDX-FileCopyrightText: 2026 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT */ + package com.nextcloud.android.common.ui.share.component import androidx.compose.foundation.layout.Arrangement @@ -26,11 +27,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel import com.nextcloud.android.common.ui.share.component.property.SharePropertyView @@ -50,18 +53,33 @@ fun AddOrEditShareBottomSheet( ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() val categories = remember { ShareCategory.entries.toList() } var selectedCategory by remember { mutableStateOf(categories.first()) } var showAdvancedSettings by remember { mutableStateOf(false) } var expandedCategories by remember { mutableStateOf(emptySet()) } + var showDiscardDialog by remember { mutableStateOf(false) } + + if (showDiscardDialog) { + DiscardDraftShareDialog( + onKeep = { showDiscardDialog = false }, + onDiscard = { + showDiscardDialog = false + viewModel.deleteShare(share.id) + viewModel.setActiveShare(null) + } + ) + } ModalBottomSheet( onDismissRequest = { - viewModel.commitPendingProperties(share.id) if (share.shareState == ShareState.DRAFT) { - viewModel.deleteShare(share.id) + showDiscardDialog = true + scope.launch { sheetState.expand() } + } else { + viewModel.commitPendingProperties(share.id) + viewModel.setActiveShare(null) } - viewModel.setActiveShare(null) }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DiscardDraftShareDialog.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DiscardDraftShareDialog.kt new file mode 100644 index 00000000..b8a5c5a5 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DiscardDraftShareDialog.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.nextcloud.android.common.ui.R + +@Composable +fun DiscardDraftShareDialog( + onKeep: () -> Unit, + onDiscard: () -> Unit +) { + AlertDialog( + onDismissRequest = onKeep, + title = { Text(stringResource(R.string.share_view_discard_draft_title)) }, + text = { Text(stringResource(R.string.share_view_discard_draft_message)) }, + confirmButton = { + TextButton(onClick = onDiscard) { + Text(stringResource(R.string.share_view_discard_draft_delete)) + } + }, + dismissButton = { + TextButton(onClick = onKeep) { + Text(stringResource(R.string.share_view_discard_draft_keep)) + } + } + ) +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareScreenState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareScreenState.kt new file mode 100644 index 00000000..aae0f0dd --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareScreenState.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.ui + +import com.nextcloud.android.common.ui.share.model.api.share.Share + +sealed class ShareScreenState { + data object Empty: ShareScreenState() + data object Loading: ShareScreenState() + data class Loaded(val shares: List): ShareScreenState() +} diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index f9272a52..adbbd303 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -58,6 +58,11 @@ Failed to fetch shares. + Discard new share? + This share hasn\'t been saved yet. Do you want to discard it? + Keep + Discard + Share not found, cannot delete. Failed to delete share. From 06dd8e1618294e6e9911a435f2c4198745a8014e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 10:22:06 +0300 Subject: [PATCH 38/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 14 ++++++- .../DeleteShareConfirmationDialog.kt | 41 +++++++++++++++++++ ui/src/main/res/values/strings.xml | 2 + 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareConfirmationDialog.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index 924b83f4..b79646ee 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -61,6 +61,7 @@ import com.nextcloud.android.common.ui.component.ContentUnavailableView import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient import com.nextcloud.android.common.ui.share.component.AddOrEditShareBottomSheet +import com.nextcloud.android.common.ui.share.component.DeleteShareConfirmationDialog import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.ui.ShareItemType @@ -166,8 +167,19 @@ private fun ShareItem( onSendEmail: (Share) -> Unit ) { var showContextMenu by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } val haptics = LocalHapticFeedback.current + if (showDeleteDialog) { + DeleteShareConfirmationDialog( + onConfirm = { + showDeleteDialog = false + onDeleteShare(share) + }, + onDismiss = { showDeleteDialog = false } + ) + } + ListItem( modifier = Modifier .fillMaxWidth(0.9f) @@ -229,8 +241,8 @@ private fun ShareItem( ) }, onClick = { - onDeleteShare(share) showContextMenu = false + showDeleteDialog = true } ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareConfirmationDialog.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareConfirmationDialog.kt new file mode 100644 index 00000000..88a741b3 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareConfirmationDialog.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.nextcloud.android.common.ui.R + +@Composable +fun DeleteShareConfirmationDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.share_view_delete_confirm_title)) }, + text = { Text(stringResource(R.string.share_view_delete_confirm_message)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + text = stringResource(R.string.common_ok), + color = MaterialTheme.colorScheme.error + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_cancel)) + } + } + ) +} diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index adbbd303..540a7009 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -55,6 +55,8 @@ Edit Send email Delete + Delete share? + This share will be permanently removed. Failed to fetch shares. From 62212b3066667dcdb744db1eed08549f0afe1207 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 10:29:27 +0300 Subject: [PATCH 39/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 42 +++++++++++++------ .../component/AddOrEditShareBottomSheet.kt | 21 ++-------- .../DeleteShareConfirmationDialog.kt | 17 +++++--- .../component/DiscardDraftShareDialog.kt | 13 +++--- .../share/model/ui/ShareItemOverlayState.kt | 14 +++++++ 5 files changed, 64 insertions(+), 43 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemOverlayState.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index b79646ee..8b535433 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -62,8 +62,10 @@ import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient import com.nextcloud.android.common.ui.share.component.AddOrEditShareBottomSheet import com.nextcloud.android.common.ui.share.component.DeleteShareConfirmationDialog +import com.nextcloud.android.common.ui.share.component.DiscardDraftShareDialog import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.ui.ShareItemOverlayState import com.nextcloud.android.common.ui.share.model.ui.ShareItemType import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository @@ -78,6 +80,18 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti val context = LocalContext.current val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } + var showDiscardDraftDialog by remember { mutableStateOf(false) } + + if (showDiscardDraftDialog) { + DiscardDraftShareDialog( + onKeep = { showDiscardDraftDialog = false }, + onDiscard = { + showDiscardDraftDialog = false + activeShare?.let { viewModel.deleteShare(it.id) } + viewModel.setActiveShare(null) + } + ) + } LaunchedEffect(errorMessageId) { errorMessageId?.let { @@ -153,7 +167,8 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti AddOrEditShareBottomSheet( share = it, sharingCapabilities = sharingCapabilities, - viewModel = viewModel + viewModel = viewModel, + onDismissDraft = { showDiscardDraftDialog = true } ) } } @@ -166,17 +181,16 @@ private fun ShareItem( onDeleteShare: (Share) -> Unit, onSendEmail: (Share) -> Unit ) { - var showContextMenu by remember { mutableStateOf(false) } - var showDeleteDialog by remember { mutableStateOf(false) } + var overlayState by remember { mutableStateOf(ShareItemOverlayState.None) } val haptics = LocalHapticFeedback.current - if (showDeleteDialog) { + if (overlayState == ShareItemOverlayState.DeleteConfirmation) { DeleteShareConfirmationDialog( onConfirm = { - showDeleteDialog = false + overlayState = ShareItemOverlayState.None onDeleteShare(share) }, - onDismiss = { showDeleteDialog = false } + onDismiss = { overlayState = ShareItemOverlayState.None } ) } @@ -188,7 +202,7 @@ private fun ShareItem( onClick = { onSelectShare(share) }, onLongClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) - showContextMenu = true + overlayState = ShareItemOverlayState.ContextMenu }, ) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), @@ -213,15 +227,18 @@ private fun ShareItem( }, trailingContent = { Box { - IconButton(onClick = { showContextMenu = true }) { + IconButton(onClick = { overlayState = ShareItemOverlayState.ContextMenu }) { Icon(Icons.Default.MoreVert, contentDescription = "More options") } - DropdownMenu(expanded = showContextMenu, onDismissRequest = { showContextMenu = false }) { + DropdownMenu( + expanded = overlayState == ShareItemOverlayState.ContextMenu, + onDismissRequest = { overlayState = ShareItemOverlayState.None } + ) { DropdownMenuItem( text = { Text(stringResource(R.string.share_view_list_item_edit)) }, onClick = { - showContextMenu = false + overlayState = ShareItemOverlayState.None onSelectShare(share) } ) @@ -229,7 +246,7 @@ private fun ShareItem( text = { Text(stringResource(R.string.share_view_list_item_send_email)) }, onClick = { onSendEmail(share) - showContextMenu = false + overlayState = ShareItemOverlayState.None } ) HorizontalDivider() @@ -241,8 +258,7 @@ private fun ShareItem( ) }, onClick = { - showContextMenu = false - showDeleteDialog = true + overlayState = ShareItemOverlayState.DeleteConfirmation } ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt index 3a34eff6..4ee145aa 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt @@ -27,13 +27,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel import com.nextcloud.android.common.ui.share.component.property.SharePropertyView @@ -49,33 +47,20 @@ import com.nextcloud.android.common.ui.share.model.ui.ShareCategory fun AddOrEditShareBottomSheet( share: Share, sharingCapabilities: SharingCapabilities, - viewModel: ShareViewModel + viewModel: ShareViewModel, + onDismissDraft: () -> Unit = {} ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scrollState = rememberScrollState() - val scope = rememberCoroutineScope() val categories = remember { ShareCategory.entries.toList() } var selectedCategory by remember { mutableStateOf(categories.first()) } var showAdvancedSettings by remember { mutableStateOf(false) } var expandedCategories by remember { mutableStateOf(emptySet()) } - var showDiscardDialog by remember { mutableStateOf(false) } - - if (showDiscardDialog) { - DiscardDraftShareDialog( - onKeep = { showDiscardDialog = false }, - onDiscard = { - showDiscardDialog = false - viewModel.deleteShare(share.id) - viewModel.setActiveShare(null) - } - ) - } ModalBottomSheet( onDismissRequest = { if (share.shareState == ShareState.DRAFT) { - showDiscardDialog = true - scope.launch { sheetState.expand() } + onDismissDraft() } else { viewModel.commitPendingProperties(share.id) viewModel.setActiveShare(null) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareConfirmationDialog.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareConfirmationDialog.kt index 88a741b3..fb10108e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareConfirmationDialog.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareConfirmationDialog.kt @@ -2,12 +2,14 @@ * Nextcloud Android Common Library * * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.android.common.ui.share.component import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -25,11 +27,14 @@ fun DeleteShareConfirmationDialog( title = { Text(stringResource(R.string.share_view_delete_confirm_title)) }, text = { Text(stringResource(R.string.share_view_delete_confirm_message)) }, confirmButton = { - TextButton(onClick = onConfirm) { - Text( - text = stringResource(R.string.common_ok), - color = MaterialTheme.colorScheme.error + FilledTonalButton( + onClick = onConfirm, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer ) + ) { + Text(stringResource(R.string.common_ok)) } }, dismissButton = { @@ -38,4 +43,4 @@ fun DeleteShareConfirmationDialog( } } ) -} +} \ No newline at end of file diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DiscardDraftShareDialog.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DiscardDraftShareDialog.kt index b8a5c5a5..3ae7faf7 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DiscardDraftShareDialog.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DiscardDraftShareDialog.kt @@ -2,12 +2,13 @@ * Nextcloud Android Common Library * * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.android.common.ui.share.component import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -24,14 +25,14 @@ fun DiscardDraftShareDialog( title = { Text(stringResource(R.string.share_view_discard_draft_title)) }, text = { Text(stringResource(R.string.share_view_discard_draft_message)) }, confirmButton = { - TextButton(onClick = onDiscard) { - Text(stringResource(R.string.share_view_discard_draft_delete)) + FilledTonalButton(onClick = onKeep) { + Text(stringResource(R.string.share_view_discard_draft_keep)) } }, dismissButton = { - TextButton(onClick = onKeep) { - Text(stringResource(R.string.share_view_discard_draft_keep)) + TextButton(onClick = onDiscard) { + Text(stringResource(R.string.share_view_discard_draft_delete)) } } ) -} +} \ No newline at end of file diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemOverlayState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemOverlayState.kt new file mode 100644 index 00000000..0e50bb29 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemOverlayState.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.model.ui + +sealed class ShareItemOverlayState { + data object None : ShareItemOverlayState() + data object ContextMenu : ShareItemOverlayState() + data object DeleteConfirmation : ShareItemOverlayState() +} From 6f3f919795d46b66871aa4edd855d8a19335f6ed Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 10:30:12 +0300 Subject: [PATCH 40/95] wip Signed-off-by: alperozturk96 --- .../com/nextcloud/android/common/ui/share/ShareScreen.kt | 6 +++--- .../{ => bottomsheet}/AddOrEditShareBottomSheet.kt | 7 +++++-- .../{ => dialog}/DeleteShareConfirmationDialog.kt | 2 +- .../component/{ => dialog}/DiscardDraftShareDialog.kt | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) rename ui/src/main/java/com/nextcloud/android/common/ui/share/component/{ => bottomsheet}/AddOrEditShareBottomSheet.kt (95%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/component/{ => dialog}/DeleteShareConfirmationDialog.kt (95%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/component/{ => dialog}/DiscardDraftShareDialog.kt (94%) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index 8b535433..2fd80eef 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -60,9 +60,9 @@ import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.component.ContentUnavailableView import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient -import com.nextcloud.android.common.ui.share.component.AddOrEditShareBottomSheet -import com.nextcloud.android.common.ui.share.component.DeleteShareConfirmationDialog -import com.nextcloud.android.common.ui.share.component.DiscardDraftShareDialog +import com.nextcloud.android.common.ui.share.component.bottomsheet.AddOrEditShareBottomSheet +import com.nextcloud.android.common.ui.share.component.dialog.DeleteShareConfirmationDialog +import com.nextcloud.android.common.ui.share.component.dialog.DiscardDraftShareDialog import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.ui.ShareItemOverlayState diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt similarity index 95% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 4ee145aa..9897bdba 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -2,11 +2,11 @@ * Nextcloud Android Common Library * * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT + * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.android.common.ui.share.component +package com.nextcloud.android.common.ui.share.component.bottomsheet import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -34,6 +34,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel +import com.nextcloud.android.common.ui.share.component.CollapsibleShareSection +import com.nextcloud.android.common.ui.share.component.RecipientSearchField +import com.nextcloud.android.common.ui.share.component.ShareSwitch import com.nextcloud.android.common.ui.share.component.property.SharePropertyView import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities import com.nextcloud.android.common.ui.share.model.api.property.clazz diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareConfirmationDialog.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DeleteShareConfirmationDialog.kt similarity index 95% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareConfirmationDialog.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DeleteShareConfirmationDialog.kt index fb10108e..cf16a865 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareConfirmationDialog.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DeleteShareConfirmationDialog.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.android.common.ui.share.component +package com.nextcloud.android.common.ui.share.component.dialog import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DiscardDraftShareDialog.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardDraftShareDialog.kt similarity index 94% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/component/DiscardDraftShareDialog.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardDraftShareDialog.kt index 3ae7faf7..85031c3c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DiscardDraftShareDialog.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardDraftShareDialog.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.nextcloud.android.common.ui.share.component +package com.nextcloud.android.common.ui.share.component.dialog import androidx.compose.material3.AlertDialog import androidx.compose.material3.FilledTonalButton From 9de1a18d9e92de8bdbf28ee6a66bcea57b070a0d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 9 Jun 2026 11:31:18 +0200 Subject: [PATCH 41/95] wip Signed-off-by: alperozturk96 --- .../ui/share/model/api/capabilities/Legacy.kt | 19 ++++++++++++++++ .../capabilities/PermissionCategoryType.kt | 22 +++++++++++++++++++ .../model/api/capabilities/SourceType.kt | 17 ++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/Legacy.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/PermissionCategoryType.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SourceType.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/Legacy.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/Legacy.kt new file mode 100644 index 00000000..cef0c5b9 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/Legacy.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.capabilities + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Legacy( + @SerialName("max_sources") + val maxSources: Long, + @SerialName("max_recipients") + val maxRecipients: Long, +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/PermissionCategoryType.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/PermissionCategoryType.kt new file mode 100644 index 00000000..2a797fe8 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/PermissionCategoryType.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.capabilities + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PermissionCategoryType( + @SerialName("class") + val classField: String, + @SerialName("display_name") + val displayName: String, + val hint: String?, + val icon: String?, + val priority: Long, +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SourceType.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SourceType.kt new file mode 100644 index 00000000..dbdfe0a6 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SourceType.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.capabilities + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SourceType( + @SerialName("class") + val classField: String, +) From 129eceebefaf45c103d20923bf944aff5ae22c09 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 9 Jun 2026 11:31:37 +0200 Subject: [PATCH 42/95] wip Signed-off-by: alperozturk96 --- gradle/verification-metadata.xml | 9 +++++ .../android/common/ui/share/ShareScreen.kt | 21 +++------- .../android/common/ui/share/ShareViewModel.kt | 5 ++- .../bottomsheet/AddOrEditShareBottomSheet.kt | 10 ++--- .../dialog/DiscardDraftShareDialog.kt | 38 ------------------- .../api/capabilities/SharingCapabilities.kt | 25 ++---------- .../ui/share/model/ui/ShareScreenState.kt | 3 ++ 7 files changed, 30 insertions(+), 81 deletions(-) delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardDraftShareDialog.kt diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 723ae9cb..bf3afce2 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -76,6 +76,7 @@ + @@ -104,6 +105,11 @@ + + + + + @@ -173,6 +179,7 @@ + @@ -280,6 +287,7 @@ + @@ -295,6 +303,7 @@ + diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index 2fd80eef..912d8800 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -62,12 +62,12 @@ import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient import com.nextcloud.android.common.ui.share.component.bottomsheet.AddOrEditShareBottomSheet import com.nextcloud.android.common.ui.share.component.dialog.DeleteShareConfirmationDialog -import com.nextcloud.android.common.ui.share.component.dialog.DiscardDraftShareDialog import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.ui.ShareItemOverlayState import com.nextcloud.android.common.ui.share.model.ui.ShareItemType import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState +import com.nextcloud.android.common.ui.share.model.ui.filtered import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -80,18 +80,6 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti val context = LocalContext.current val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } - var showDiscardDraftDialog by remember { mutableStateOf(false) } - - if (showDiscardDraftDialog) { - DiscardDraftShareDialog( - onKeep = { showDiscardDraftDialog = false }, - onDiscard = { - showDiscardDraftDialog = false - activeShare?.let { viewModel.deleteShare(it.id) } - viewModel.setActiveShare(null) - } - ) - } LaunchedEffect(errorMessageId) { errorMessageId?.let { @@ -141,7 +129,7 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - itemsIndexed(state.shares, key = { _, share -> share.id }) { index, share -> + itemsIndexed(state.shares.filtered(), key = { _, share -> share.id }) { index, share -> val type = ShareItemType.type(index, state.shares.lastIndex) if (index == 0) { @@ -168,7 +156,10 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti share = it, sharingCapabilities = sharingCapabilities, viewModel = viewModel, - onDismissDraft = { showDiscardDraftDialog = true } + onDismissDraft = { draftShare -> + activeShare?.let { viewModel.deleteShare(draftShare.id) } + viewModel.setActiveShare(null) + } ) } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 42298b3e..7120f697 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -21,6 +21,7 @@ import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateR import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState +import com.nextcloud.android.common.ui.share.model.ui.filtered import com.nextcloud.android.common.ui.share.repository.ShareRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -98,7 +99,7 @@ class ShareViewModel( val result = repository.fetchShares(sourceClass, lastShareID, limit) handleResult(result, R.string.share_view_fetch_error_message)?.let { fetched -> _state.update { - if (fetched.isEmpty()) ShareScreenState.Empty + if (fetched.filtered().isEmpty()) ShareScreenState.Empty else ShareScreenState.Loaded(fetched) } } @@ -237,7 +238,7 @@ class ShareViewModel( handleResult(result, R.string.share_view_delete_error_message)?.let { val remaining = currentShares.filterNot { it.id == id } _state.update { - if (remaining.isEmpty()) ShareScreenState.Empty + if (remaining.filtered().isEmpty()) ShareScreenState.Empty else ShareScreenState.Loaded(remaining) } if (_activeShare.value?.id == id) _activeShare.update { null } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 9897bdba..89044d8f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -51,7 +51,7 @@ fun AddOrEditShareBottomSheet( share: Share, sharingCapabilities: SharingCapabilities, viewModel: ShareViewModel, - onDismissDraft: () -> Unit = {} + onDismissDraft: (Share) -> Unit = {} ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scrollState = rememberScrollState() @@ -63,7 +63,7 @@ fun AddOrEditShareBottomSheet( ModalBottomSheet( onDismissRequest = { if (share.shareState == ShareState.DRAFT) { - onDismissDraft() + onDismissDraft(share) } else { viewModel.commitPendingProperties(share.id) viewModel.setActiveShare(null) @@ -156,17 +156,17 @@ private fun PermissionCategories( onToggleCategory: (String) -> Unit, viewModel: ShareViewModel ) { - sharingCapabilities.permissionCategories + sharingCapabilities.permissionCategoryTypes .sortedBy { it.priority } .forEach { sharingCapability -> - key(sharingCapability.class_field) { + key(sharingCapability.classField) { CollapsibleShareSection( label = sharingCapability.displayName, isExpanded = sharingCapability.displayName in expandedCategories, onToggle = { onToggleCategory(sharingCapability.displayName) }, ) { share.permissions - .filter { permission -> permission.category == sharingCapability.class_field } + .filter { permission -> permission.category == sharingCapability.classField } .sortedBy { it.displayName } .forEach { permission -> key(permission.clazz) { diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardDraftShareDialog.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardDraftShareDialog.kt deleted file mode 100644 index 85031c3c..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardDraftShareDialog.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.nextcloud.android.common.ui.share.component.dialog - -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.nextcloud.android.common.ui.R - -@Composable -fun DiscardDraftShareDialog( - onKeep: () -> Unit, - onDiscard: () -> Unit -) { - AlertDialog( - onDismissRequest = onKeep, - title = { Text(stringResource(R.string.share_view_discard_draft_title)) }, - text = { Text(stringResource(R.string.share_view_discard_draft_message)) }, - confirmButton = { - FilledTonalButton(onClick = onKeep) { - Text(stringResource(R.string.share_view_discard_draft_keep)) - } - }, - dismissButton = { - TextButton(onClick = onDiscard) { - Text(stringResource(R.string.share_view_discard_draft_delete)) - } - } - ) -} \ No newline at end of file diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt index 545f925c..304d3592 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt @@ -15,25 +15,8 @@ data class SharingCapabilities( @SerialName("api_versions") val apiVersions: List, val legacy: Legacy, - @SerialName("permission_categories") - val permissionCategories: List, -) - -@Serializable -data class Legacy( - @SerialName("max_sources") - val maxSources: Long, - @SerialName("max_recipients") - val maxRecipients: Long, -) - -@Serializable -data class PermissionCategory( - @SerialName("class") - val class_field: String, - @SerialName("display_name") - val displayName: String, - val hint: String?, - val icon: String?, - val priority: Long, + @SerialName("source_types") + val sourceTypes: List, + @SerialName("permission_category_types") + val permissionCategoryTypes: List, ) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareScreenState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareScreenState.kt index aae0f0dd..1b912e17 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareScreenState.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareScreenState.kt @@ -8,9 +8,12 @@ package com.nextcloud.android.common.ui.share.model.ui import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.api.state.ShareState sealed class ShareScreenState { data object Empty: ShareScreenState() data object Loading: ShareScreenState() data class Loaded(val shares: List): ShareScreenState() } + +fun List.filtered(): List = filter { it.shareState != ShareState.DRAFT } From a219203f23adec7ca1c5da1096bdfc31db7a5af0 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 9 Jun 2026 12:03:53 +0200 Subject: [PATCH 43/95] wip Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 89044d8f..15c0dfc0 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -148,6 +150,21 @@ private fun CategorySelector( } } +// TODO ADD send button +// TODO disable multi receipients +// TODO implement Anyone --> /ocs/v2.php/apps/sharing/api/v1/share/{id}/recipient +// --> Add a recipient to a share use correspongind class value --> doesnt matter use uuid string : "value": "string", "instance": "string" + +// TODO discuss with laura for note to recepipent expiraitaion date proeprties etc should be collabls + +// TODO send ISO 8601 as string to backend for expiration date + +// TODO for COPY link use Recepient.Secret.URL side note: if value is updatable user can edit the TOKEN directly which is value + + + +// TODO: Backend will change and provide us bundled permissions or list of custom permissions. +// USE THIS FOR BOTH OPTION @Composable private fun PermissionCategories( share: Share, @@ -156,6 +173,27 @@ private fun PermissionCategories( onToggleCategory: (String) -> Unit, viewModel: ShareViewModel ) { + var expanded by remember { mutableStateOf(false) } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + share.permissions.forEach { + key(it.displayName) { + DropdownMenuItem( + text = { Text(it.displayName) }, + onClick = { /* Do something... */ } + ) + } + } + + DropdownMenuItem( + text = { Text("Option 2") }, + onClick = { /* Do something... */ } + ) + } + sharingCapabilities.permissionCategoryTypes .sortedBy { it.priority } .forEach { sharingCapability -> From 87da22b3e6bc20f4829a0b0f995efce1f2cd166f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 9 Jun 2026 12:14:28 +0200 Subject: [PATCH 44/95] wip Signed-off-by: alperozturk96 --- .../share/component/bottomsheet/AddOrEditShareBottomSheet.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 15c0dfc0..946a1e73 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -161,7 +161,11 @@ private fun CategorySelector( // TODO for COPY link use Recepient.Secret.URL side note: if value is updatable user can edit the TOKEN directly which is value +// TODO display error messages and handle them via debounce mechanism +// TODO fetch shares currently returns all shares SOURCE ID will be implemented so that u can only show related shares + +// TODO show share icon from first recipient but check with laura // TODO: Backend will change and provide us bundled permissions or list of custom permissions. // USE THIS FOR BOTH OPTION From 5d91b88c30eb161716108972db02908f58a58abc Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 9 Jun 2026 15:56:33 +0200 Subject: [PATCH 45/95] wip Signed-off-by: alperozturk96 --- .../share/component/bottomsheet/AddOrEditShareBottomSheet.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 946a1e73..bd08b55b 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -177,7 +177,8 @@ private fun PermissionCategories( onToggleCategory: (String) -> Unit, viewModel: ShareViewModel ) { - var expanded by remember { mutableStateOf(false) } + /* + var expanded by remember { mutableStateOf(false) } DropdownMenu( expanded = expanded, @@ -197,6 +198,8 @@ private fun PermissionCategories( onClick = { /* Do something... */ } ) } + */ + sharingCapabilities.permissionCategoryTypes .sortedBy { it.priority } From caf020754fac08dfe57c99b85265e79d48dc9912 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 12:31:06 +0200 Subject: [PATCH 46/95] disable multi recipients Signed-off-by: alperozturk96 --- .../android/common/ui/share/component/RecipientSearchField.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt index 9dfd0a6f..41ed063d 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt @@ -78,6 +78,7 @@ fun RecipientSearchField( } RecipientSearchDropdown( + enabled = share.recipients.isEmpty(), query = query, expanded = expanded, onQueryChange = { @@ -145,6 +146,7 @@ private fun RecipientChipRow( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RecipientSearchDropdown( + enabled: Boolean, query: String, expanded: Boolean, onQueryChange: (String) -> Unit, @@ -160,6 +162,7 @@ private fun RecipientSearchDropdown( value = query, onValueChange = onQueryChange, label = { Text(stringResource(R.string.share_view_invited_category_label)) }, + enabled = enabled, modifier = Modifier .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true) .fillMaxWidth(), From e4c0837d3165aa64dd272c2af006160ee32f9bdd Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 12:31:12 +0200 Subject: [PATCH 47/95] disable multi recipients Signed-off-by: alperozturk96 --- .../ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index bd08b55b..916d03a5 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -151,7 +151,6 @@ private fun CategorySelector( } // TODO ADD send button -// TODO disable multi receipients // TODO implement Anyone --> /ocs/v2.php/apps/sharing/api/v1/share/{id}/recipient // --> Add a recipient to a share use correspongind class value --> doesnt matter use uuid string : "value": "string", "instance": "string" From 7a4480641ba4b2a03334e2eae4243a0a0caf2cb2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 14:07:09 +0200 Subject: [PATCH 48/95] add secret and buttons Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 82 ++++++++++++++++++- .../share/model/api/recipients/Recipient.kt | 5 +- .../ui/share/model/api/secret/Secret.kt | 17 ++++ .../common/ui/share/model/api/share/Share.kt | 18 ++++ .../share/repository/MockShareRepository.kt | 28 +++++-- 5 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/secret/Secret.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 916d03a5..05d8b936 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -10,13 +10,19 @@ package com.nextcloud.android.common.ui.share.component.bottomsheet import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SegmentedButton @@ -29,9 +35,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R @@ -46,6 +55,7 @@ import com.nextcloud.android.common.ui.share.model.api.property.priority import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.model.ui.ShareCategory +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -116,6 +126,74 @@ fun AddOrEditShareBottomSheet( viewModel = viewModel ) } + + if (share.readyToSend()) { + val localClipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + Buttons(onCopyLink = { + scope.launch { + share.getClipEntry()?.let { clipEntry -> + localClipboard.setClipEntry(clipEntry) + } + } + }, onSend = { + viewModel.updateState(share.id, ShareState.ACTIVE) + }) + } + } + } +} + +@Composable +private fun Buttons( + onCopyLink: () -> Unit = {}, + onSend: () -> Unit = {}, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button( + onClick = onCopyLink, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_link), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.share_view_copy_action), + modifier = Modifier.padding(start = 8.dp), + ) + } + + Button( + onClick = onSend, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.share_view_send_action), + modifier = Modifier.padding(start = 8.dp), + ) } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt index 36d876b1..5f900004 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt @@ -8,6 +8,7 @@ package com.nextcloud.android.common.ui.share.model.api.recipients import com.nextcloud.android.common.ui.share.model.api.icon.Icon +import com.nextcloud.android.common.ui.share.model.api.secret.Secret import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -23,5 +24,7 @@ data class Recipient( @SerialName("display_name") val displayName: String, - val icon: Icon? = null + val icon: Icon? = null, + + val secret: Secret ) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/secret/Secret.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/secret/Secret.kt new file mode 100644 index 00000000..a924e83d --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/secret/Secret.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.secret + +import kotlinx.serialization.Serializable + +@Serializable +data class Secret( + val updatable: Boolean, + val value: String?, + val url: String? +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 3d2032e4..51bf0067 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -7,7 +7,10 @@ package com.nextcloud.android.common.ui.share.model.api.share +import android.content.ClipData import android.content.Context +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.toClipEntry import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.model.api.owner.Owner import com.nextcloud.android.common.ui.share.model.api.permission.Permission @@ -38,6 +41,21 @@ data class Share( val permissions: List ) { + fun getClipEntry(): ClipEntry? { + val label = recipients.first().displayName + val link = recipients.first().secret.url + + return if (link != null) { + ClipData.newPlainText(label, link).toClipEntry() + } else { + null + } + } + + fun readyToSend(): Boolean { + return sources.isNotEmpty() && recipients.isNotEmpty() && permissions.isNotEmpty() + } + fun title(context: Context): String { return if (shareState == ShareState.DRAFT) { context.getString(R.string.share_view_bottom_sheet_new_title) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt index f6a63048..4c3fe4be 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -22,6 +22,7 @@ import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest +import com.nextcloud.android.common.ui.share.model.api.secret.Secret import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.source.Source import com.nextcloud.android.common.ui.share.model.api.state.ShareState @@ -123,7 +124,8 @@ class MockShareRepository : ShareRepository { icon = Icon( light = "https://mock/icons/user_light.png", dark = "https://mock/icons/user_dark.png" - ) + ), + secret = Secret(false, value = "", url = "") ) ) ), @@ -140,7 +142,8 @@ class MockShareRepository : ShareRepository { icon = Icon( light = "https://mock/icons/group_light.png", dark = "https://mock/icons/group_dark.png" - ) + ), + secret = Secret(false, value = "", url = "") ) ), owner = Owner( @@ -181,7 +184,8 @@ class MockShareRepository : ShareRepository { icon = Icon( light = "https://mock/icons/external_light.png", dark = "https://mock/icons/external_dark.png" - ) + ), + secret = Secret(false, value = "", url = "") ) ), owner = Owner( @@ -207,7 +211,8 @@ class MockShareRepository : ShareRepository { icon = Icon( light = "https://mock/icons/federated_light.png", dark = "https://mock/icons/federated_dark.png" - ) + ), + secret = Secret(false, value = "", url = "") ) ), owner = Owner( @@ -235,7 +240,8 @@ class MockShareRepository : ShareRepository { icon = Icon( light = "https://mock/icons/user_light.png", dark = "https://mock/icons/user_dark.png" - ) + ), + secret = Secret(false, value = "", url = "") ), Recipient( clazz = "group", @@ -244,7 +250,8 @@ class MockShareRepository : ShareRepository { icon = Icon( light = "https://mock/icons/group_light.png", dark = "https://mock/icons/group_dark.png" - ) + ), + secret = Secret(false, value = "", url = "") ), Recipient( clazz = "mail", @@ -253,7 +260,8 @@ class MockShareRepository : ShareRepository { icon = Icon( light = "https://mock/icons/external_light.png", dark = "https://mock/icons/external_dark.png" - ) + ), + secret = Secret(false, value = "", url = "") ), Recipient( clazz = "federated", @@ -263,7 +271,8 @@ class MockShareRepository : ShareRepository { icon = Icon( light = "https://mock/icons/federated_light.png", dark = "https://mock/icons/federated_dark.png" - ) + ), + secret = Secret(false, value = "", url = "") ) ) @@ -373,7 +382,8 @@ class MockShareRepository : ShareRepository { clazz = request.clazz, value = request.value, instance = request.instance, - displayName = request.value + displayName = request.value, + secret = Secret(false, value = "", url = "") ) val updated = current.copy( recipients = current.recipients + newRecipient, From a207da6ed99a0551c1a64e1a9e6611ca28985b58 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 14:12:46 +0200 Subject: [PATCH 49/95] add secret and buttons Signed-off-by: alperozturk96 --- .../android/common/ui/share/model/api/secret/Secret.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/secret/Secret.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/secret/Secret.kt index a924e83d..b951e6d0 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/secret/Secret.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/secret/Secret.kt @@ -12,6 +12,6 @@ import kotlinx.serialization.Serializable @Serializable data class Secret( val updatable: Boolean, - val value: String?, - val url: String? + val value: String? = null, + val url: String? = null ) From 61c68bb0e490a897aff2824c467999865905a24c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 14:14:25 +0200 Subject: [PATCH 50/95] fix enabled Signed-off-by: alperozturk96 --- .../nextcloud/android/common/ui/share/model/api/share/Share.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 51bf0067..1d8b473e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -53,7 +53,7 @@ data class Share( } fun readyToSend(): Boolean { - return sources.isNotEmpty() && recipients.isNotEmpty() && permissions.isNotEmpty() + return sources.isNotEmpty() && recipients.isNotEmpty() && permissions.any { it.enabled } } fun title(context: Context): String { From ab9cdb82db5d665ee49dec0f5ef024c2babd9b10 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 14:38:44 +0200 Subject: [PATCH 51/95] use ISO8601 Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 3 --- .../property/datepicker/ShareDatePicker.kt | 22 ++++++++------- .../datepicker/util/ShareDateFormatter.kt | 27 ++++++++++--------- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 05d8b936..e9df46a1 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -228,14 +228,11 @@ private fun CategorySelector( } } -// TODO ADD send button // TODO implement Anyone --> /ocs/v2.php/apps/sharing/api/v1/share/{id}/recipient // --> Add a recipient to a share use correspongind class value --> doesnt matter use uuid string : "value": "string", "instance": "string" // TODO discuss with laura for note to recepipent expiraitaion date proeprties etc should be collabls -// TODO send ISO 8601 as string to backend for expiration date - // TODO for COPY link use Recepient.Secret.URL side note: if value is updatable user can edit the TOKEN directly which is value // TODO display error messages and handle them via debounce mechanism diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt index a99fae18..186b9238 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt @@ -37,7 +37,6 @@ fun ShareDatePicker(property: PropertyDate, onDateSelected: (String) -> Unit) { var showDatePicker by remember { mutableStateOf(false) } var dateValue by remember { mutableStateOf(property.value ?: "") } - Box(modifier = Modifier.fillMaxWidth()) { OutlinedTextField( value = dateValue, @@ -62,19 +61,21 @@ fun ShareDatePicker(property: PropertyDate, onDateSelected: (String) -> Unit) { } if (showDatePicker) { - DatePickerModal(formatter, onDateSelected = { - dateValue = it ?: "" - onDateSelected(dateValue) - }, onDismiss = { - showDatePicker = false - }) + DatePickerModal( + formatter = formatter, + onDateSelected = { displayDate, isoDate -> + dateValue = displayDate ?: "" + onDateSelected(isoDate ?: "") + }, + onDismiss = { showDatePicker = false } + ) } } @Composable private fun DatePickerModal( formatter: ShareDateFormatter, - onDateSelected: (String?) -> Unit, + onDateSelected: (displayDate: String?, isoDate: String?) -> Unit, onDismiss: () -> Unit ) { val datePickerState = rememberDatePickerState() @@ -83,7 +84,10 @@ private fun DatePickerModal( onDismissRequest = onDismiss, confirmButton = { TextButton(onClick = { - onDateSelected(formatter.formatDate(datePickerState)) + onDateSelected( + formatter.formatDisplayDate(datePickerState), + formatter.formatIso8601Date(datePickerState) + ) onDismiss() }) { Text(stringResource(R.string.common_ok)) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt index 7caca750..b63e69f9 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt @@ -11,21 +11,24 @@ import androidx.compose.material3.DatePickerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.getSelectedDate import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate +import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale class ShareDateFormatter { - companion object { - private const val PATTERN = "MM-dd-yyyy" - } - fun getDisplayName(property: PropertyDate): String { - return property.displayName + " ($PATTERN)" - } + fun getDisplayName(property: PropertyDate): String = property.displayName @OptIn(ExperimentalMaterial3Api::class) - fun formatDate(datePickerState: DatePickerState): String? { - val date = datePickerState.getSelectedDate() - val formatter = DateTimeFormatter.ofPattern(PATTERN) - return date?.format(formatter) - } -} \ No newline at end of file + fun formatDisplayDate(datePickerState: DatePickerState): String? = + datePickerState.getSelectedDate()?.format( + DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(Locale.getDefault()) + ) + + @OptIn(ExperimentalMaterial3Api::class) + fun formatIso8601Date(datePickerState: DatePickerState): String? = + datePickerState.getSelectedDate() + ?.atStartOfDay(ZoneId.systemDefault()) + ?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) +} From dacb751658214264640ddb63cd6fa61444c8bcd7 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 15:09:25 +0200 Subject: [PATCH 52/95] adopt latest open api Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 10 +++++++--- .../ui/share/component/RecipientSearchField.kt | 2 +- .../api/capabilities/PermissionCategoryType.kt | 5 +++-- .../api/capabilities/SharingCapabilities.kt | 2 +- .../ui/share/model/api/permission/Permission.kt | 7 ++++++- .../ui/share/model/api/request/ShareRequests.kt | 13 +++++++++++++ .../common/ui/share/model/api/source/Source.kt | 5 ++++- .../ui/share/repository/MockShareRepository.kt | 14 ++++++++++++-- .../share/repository/ShareRemoteRepository.kt | 17 +++++++++++++++-- .../ui/share/repository/ShareRepository.kt | 8 +++++++- 10 files changed, 69 insertions(+), 14 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index 912d8800..d49523c0 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -48,8 +48,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -60,6 +60,7 @@ import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.component.ContentUnavailableView import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient +import com.nextcloud.android.common.ui.share.component.RecipientIcon import com.nextcloud.android.common.ui.share.component.bottomsheet.AddOrEditShareBottomSheet import com.nextcloud.android.common.ui.share.component.dialog.DeleteShareConfirmationDialog import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities @@ -77,13 +78,13 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti val errorMessageId by viewModel.errorMessageId.collectAsState() val screenState by viewModel.state.collectAsState() val activeShare by viewModel.activeShare.collectAsState() - val context = LocalContext.current val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } + val resources = LocalResources.current LaunchedEffect(errorMessageId) { errorMessageId?.let { - snackbarHostState.showSnackbar(context.getString(it)) + snackbarHostState.showSnackbar(resources.getString(it)) viewModel.updateErrorMessage(null) } } @@ -209,6 +210,9 @@ private fun ShareItem( style = MaterialTheme.typography.titleSmall ) }, + leadingContent = { + share.recipients.first().icon?.let { RecipientIcon(icon = it) } + }, supportingContent = { Text( text = share.shareState.name, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt index 41ed063d..ade91d16 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt @@ -216,7 +216,7 @@ private fun RecipientDropdownContent( } @Composable -private fun RecipientIcon(icon: Icon, modifier: Modifier = Modifier) { +fun RecipientIcon(icon: Icon, modifier: Modifier = Modifier) { val isDark = isSystemInDarkTheme() val url = if (isDark) icon.dark ?: icon.light else icon.light ?: icon.dark diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/PermissionCategoryType.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/PermissionCategoryType.kt index 2a797fe8..17fd74cc 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/PermissionCategoryType.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/PermissionCategoryType.kt @@ -7,6 +7,7 @@ package com.nextcloud.android.common.ui.share.model.api.capabilities +import com.nextcloud.android.common.ui.share.model.api.icon.Icon import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -16,7 +17,7 @@ data class PermissionCategoryType( val classField: String, @SerialName("display_name") val displayName: String, - val hint: String?, - val icon: String?, + val hint: String? = null, + val icon: Icon? = null, val priority: Long, ) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt index 304d3592..13452ea9 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt @@ -14,7 +14,7 @@ import kotlinx.serialization.Serializable data class SharingCapabilities( @SerialName("api_versions") val apiVersions: List, - val legacy: Legacy, + val legacy: Legacy? = null, @SerialName("source_types") val sourceTypes: List, @SerialName("permission_category_types") diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt index eae0441a..1e8c62f9 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt @@ -15,10 +15,15 @@ data class Permission( @SerialName("class") val clazz: String, + @SerialName("source_class") + val sourceClass: String? = null, + @SerialName("display_name") val displayName: String, - val category: String?, + val hint: String? = null, + + val category: String? = null, val enabled: Boolean ) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt index b0502ec6..8565a604 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt @@ -20,6 +20,7 @@ data class GetShareRequest( @Serializable data class UpdateShareStateRequest( + @SerialName("state") val shareState: ShareState ) @@ -56,3 +57,15 @@ data class UpdateSharePermissionRequest( val enabled: Boolean ) + +@Serializable +data class UpdateShareRecipientSecretRequest( + @SerialName("class") + val clazz: String, + + val value: String, + + val instance: String? = null, + + val secret: String +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/source/Source.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/source/Source.kt index b5dfa16f..0635a4bd 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/source/Source.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/source/Source.kt @@ -7,6 +7,7 @@ package com.nextcloud.android.common.ui.share.model.api.source +import com.nextcloud.android.common.ui.share.model.api.icon.Icon import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -18,5 +19,7 @@ data class Source( val value: String, @SerialName("display_name") - val displayName: String + val displayName: String, + + val icon: Icon? = null ) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt index 4c3fe4be..2edf350e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -21,6 +21,7 @@ import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipientSecretRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest import com.nextcloud.android.common.ui.share.model.api.secret.Secret import com.nextcloud.android.common.ui.share.model.api.share.Share @@ -227,7 +228,7 @@ class MockShareRepository : ShareRepository { ) override suspend fun fetchRecipients( - recipientTypeClass: String?, + recipientTypeClasses: List?, query: String, limit: Int, offset: Int @@ -277,7 +278,7 @@ class MockShareRepository : ShareRepository { ) val filtered = all - .filter { recipientTypeClass == null || it.clazz == recipientTypeClass } + .filter { recipientTypeClasses.isNullOrEmpty() || it.clazz in recipientTypeClasses } .filter { it.displayName.contains(query, ignoreCase = true) || it.value.contains(query, ignoreCase = true) } .drop(offset) .take(limit) @@ -422,6 +423,15 @@ class MockShareRepository : ShareRepository { return NetworkResult.Success(updated) } + override suspend fun updateShareRecipientSecret( + id: String, + request: UpdateShareRecipientSecretRequest + ): NetworkResult { + val index = mockShares.indexOfFirst { it.id == id } + val current = if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList()) + return NetworkResult.Success(current) + } + override suspend fun updateSharePermission( id: String, request: UpdateSharePermissionRequest diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index 20ceac7c..6498564e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -19,6 +19,7 @@ import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipientSecretRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest import com.nextcloud.android.common.ui.share.model.api.share.Share import okhttp3.RequestBody.Companion.toRequestBody @@ -35,14 +36,14 @@ class ShareRemoteRepository( } override suspend fun fetchRecipients( - recipientTypeClass: String?, + recipientTypeClasses: List?, query: String, limit: Int, offset: Int ): NetworkResult> { val queryParams = buildString { append("?query=$query&limit=$limit&offset=$offset") - recipientTypeClass?.let { append("&recipientTypeClass=$it") } + recipientTypeClasses?.forEach { append("&recipientTypeClasses[]=$it") } } return client.executeRequest( endpoint = "$RECIPIENTS_ENDPOINT$queryParams", @@ -186,4 +187,16 @@ class ShareRemoteRepository( ) { body -> json.decodeFromString>(body).ocs.data } + + override suspend fun updateShareRecipientSecret( + id: String, + request: UpdateShareRecipientSecretRequest + ): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/recipient/secret", + method = HttpMethod.PUT, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { body -> + json.decodeFromString>(body).ocs.data + } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt index d10524b8..8ce580f3 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt @@ -13,13 +13,14 @@ import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipientSecretRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest import com.nextcloud.android.common.ui.share.model.api.share.Share interface ShareRepository { suspend fun fetchRecipients( - recipientTypeClass: String?, + recipientTypeClasses: List?, query: String, limit: Int, offset: Int @@ -77,4 +78,9 @@ interface ShareRepository { id: String, request: UpdateSharePermissionRequest ): NetworkResult + + suspend fun updateShareRecipientSecret( + id: String, + request: UpdateShareRecipientSecretRequest + ): NetworkResult } From 094dbec46037f8b628734a605392138b4cf63610 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 15:34:28 +0200 Subject: [PATCH 53/95] property updates Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 143 +++++++++++------- .../bottomsheet/AddOrEditShareBottomSheet.kt | 1 - .../component/property/SharePropertyView.kt | 88 ++++++----- 3 files changed, 137 insertions(+), 95 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 7120f697..31376752 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -25,6 +25,11 @@ import com.nextcloud.android.common.ui.share.model.ui.filtered import com.nextcloud.android.common.ui.share.repository.ShareRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce @@ -52,7 +57,12 @@ class ShareViewModel( private val _errorMessageId = MutableStateFlow(null) val errorMessageId: StateFlow = _errorMessageId - private val pendingProperties = mutableMapOf() + private data class PendingPropertyUpdate(val job: Job, val shareId: String, val value: String) + + private val pendingPropertyJobs = mutableMapOf() + + private val _propertyErrors = MutableStateFlow>(emptyMap()) + val propertyErrors: StateFlow> = _propertyErrors private val currentShares: List get() = (_state.value as? ShareScreenState.Loaded)?.shares ?: emptyList() @@ -97,11 +107,10 @@ class ShareViewModel( _errorMessageId.update { null } val result = repository.fetchShares(sourceClass, lastShareID, limit) - handleResult(result, R.string.share_view_fetch_error_message)?.let { fetched -> - _state.update { - if (fetched.filtered().isEmpty()) ShareScreenState.Empty - else ShareScreenState.Loaded(fetched) - } + val fetched = handleResult(result, R.string.share_view_fetch_error_message) ?: return@launch + _state.update { + if (fetched.filtered().isEmpty()) ShareScreenState.Empty + else ShareScreenState.Loaded(fetched) } } } @@ -111,10 +120,10 @@ class ShareViewModel( _errorMessageId.update { null } val result = repository.fetchShare(id, request) - handleResult(result, R.string.share_view_fetch_error_message)?.let { share -> - _activeShare.update { share } - replaceInList(share) - } + val share = handleResult(result, R.string.share_view_fetch_error_message) ?: return@launch + + _activeShare.update { share } + replaceInList(share) } } // endregion @@ -137,12 +146,18 @@ class ShareViewModel( // region state fun updateState(id: String, shareState: ShareState) { - viewModelScope.launch(Dispatchers.IO) { - val result = repository.updateShareState(id, UpdateShareStateRequest(shareState)) - handleResult(result, R.string.share_view_update_error_message)?.let { updated -> + viewModelScope.launch { + flushPendingProperties(id) + val result = withContext(Dispatchers.IO) { + repository.updateShareState(id, UpdateShareStateRequest(shareState)) + } + val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + if (shareState == ShareState.ACTIVE) { + _activeShare.update { null } + } else { _activeShare.update { updated } - replaceInList(updated) } + replaceInList(updated) } } // endregion @@ -152,20 +167,18 @@ class ShareViewModel( viewModelScope.launch(Dispatchers.IO) { val clazz = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType" val result = repository.addShareSource(id, AddSourceRequest(clazz, value)) - handleResult(result, R.string.share_view_update_error_message)?.let { updated -> - _activeShare.update { updated } - replaceInList(updated) - } + val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + _activeShare.update { updated } + replaceInList(updated) } } fun removeSource(id: String, clazz: String, value: String) { viewModelScope.launch(Dispatchers.IO) { val result = repository.removeShareSource(id, clazz, value) - handleResult(result, R.string.share_view_update_error_message)?.let { updated -> - _activeShare.update { updated } - replaceInList(updated) - } + val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + _activeShare.update { updated } + replaceInList(updated) } } // endregion @@ -174,20 +187,18 @@ class ShareViewModel( fun addRecipient(id: String, clazz: String, value: String, instance: String? = null) { viewModelScope.launch(Dispatchers.IO) { val result = repository.addShareRecipient(id, AddRecipientRequest(clazz, value, instance)) - handleResult(result, R.string.share_view_update_error_message)?.let { updated -> - _activeShare.update { updated } - replaceInList(updated) - } + val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + _activeShare.update { updated } + replaceInList(updated) } } fun removeRecipient(id: String, clazz: String, value: String, instance: String? = null) { viewModelScope.launch(Dispatchers.IO) { val result = repository.removeShareRecipient(id, clazz, value, instance) - handleResult(result, R.string.share_view_update_error_message)?.let { updated -> - _activeShare.update { updated } - replaceInList(updated) - } + val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + _activeShare.update { updated } + replaceInList(updated) } } // endregion @@ -196,25 +207,47 @@ class ShareViewModel( fun updateProperty(id: String, clazz: String, value: String?) { viewModelScope.launch(Dispatchers.IO) { val result = repository.updateShareProperty(id, UpdateSharePropertyRequest(clazz, value)) - handleResult(result, R.string.share_view_update_error_message)?.let { updated -> - _activeShare.update { updated } - replaceInList(updated) - } + val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + _activeShare.update { updated } + replaceInList(updated) } } - fun updatePropertyLocally(clazz: String, value: String) { - pendingProperties[clazz] = value + fun updatePropertyDebounced(shareId: String, clazz: String, value: String) { + pendingPropertyJobs[clazz]?.job?.cancel() + val job = viewModelScope.launch { + delay(PROPERTY_DEBOUNCE_DELAY_MS) + pendingPropertyJobs.remove(clazz) + withContext(Dispatchers.IO) { executePropertyUpdate(shareId, clazz, value) } + } + pendingPropertyJobs[clazz] = PendingPropertyUpdate(job, shareId, value) } - fun commitPendingProperties(shareId: String) { - if (pendingProperties.isEmpty()) return + private suspend fun flushPendingProperties(shareId: String) { + val snapshot = pendingPropertyJobs.toMap() + snapshot.values.forEach { it.job.cancel() } + pendingPropertyJobs.clear() + if (snapshot.isEmpty()) return + withContext(Dispatchers.IO) { + coroutineScope { + snapshot.entries.map { (clazz, pending) -> + async { executePropertyUpdate(shareId, clazz, pending.value) } + }.awaitAll() + } + } + } - viewModelScope.launch(Dispatchers.IO) { - pendingProperties.forEach { (clazz, value) -> - repository.updateShareProperty(shareId, UpdateSharePropertyRequest(clazz, value)) + private suspend fun executePropertyUpdate(shareId: String, clazz: String, value: String) { + when (val result = repository.updateShareProperty(shareId, UpdateSharePropertyRequest(clazz, value))) { + is NetworkResult.Success -> { + _propertyErrors.update { it - clazz } + _activeShare.update { result.data } + replaceInList(result.data) + } + is NetworkResult.ServerError, + is NetworkResult.NetworkException -> { + _propertyErrors.update { it + (clazz to R.string.share_view_update_error_message) } } - pendingProperties.clear() } } // endregion @@ -223,10 +256,9 @@ class ShareViewModel( fun updatePermission(id: String, clazz: String, enabled: Boolean) { viewModelScope.launch(Dispatchers.IO) { val result = repository.updateSharePermission(id, UpdateSharePermissionRequest(clazz, enabled)) - handleResult(result, R.string.share_view_update_error_message)?.let { updated -> - _activeShare.update { updated } - replaceInList(updated) - } + val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + _activeShare.update { updated } + replaceInList(updated) } } // endregion @@ -235,14 +267,14 @@ class ShareViewModel( fun deleteShare(id: String) { viewModelScope.launch(Dispatchers.IO) { val result = repository.deleteShare(id) - handleResult(result, R.string.share_view_delete_error_message)?.let { - val remaining = currentShares.filterNot { it.id == id } - _state.update { - if (remaining.filtered().isEmpty()) ShareScreenState.Empty - else ShareScreenState.Loaded(remaining) - } - if (_activeShare.value?.id == id) _activeShare.update { null } + handleResult(result, R.string.share_view_delete_error_message) ?: return@launch + + val remaining = currentShares.filterNot { it.id == id } + _state.update { + if (remaining.filtered().isEmpty()) ShareScreenState.Empty + else ShareScreenState.Loaded(remaining) } + if (_activeShare.value?.id == id) _activeShare.update { null } } } // endregion @@ -253,11 +285,16 @@ class ShareViewModel( } fun setActiveShare(value: Share?) { + _propertyErrors.update { emptyMap() } _activeShare.update { value } } // endregion // region private + companion object { + private const val PROPERTY_DEBOUNCE_DELAY_MS = 1000L + } + private fun replaceInList(updated: Share) { val shares = currentShares.ifEmpty { return } _state.update { ShareScreenState.Loaded(shares.map { if (it.id == updated.id) updated else it }) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index e9df46a1..09b2b6b6 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -77,7 +77,6 @@ fun AddOrEditShareBottomSheet( if (share.shareState == ShareState.DRAFT) { onDismissDraft(share) } else { - viewModel.commitPendingProperties(share.id) viewModel.setActiveShare(null) } }, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt index 974e381e..0ad0c739 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt @@ -7,60 +7,80 @@ package com.nextcloud.android.common.ui.share.component.property +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel -import com.nextcloud.android.common.ui.share.component.property.datepicker.ShareDatePicker import com.nextcloud.android.common.ui.share.component.ShareSwitch +import com.nextcloud.android.common.ui.share.component.property.datepicker.ShareDatePicker import com.nextcloud.android.common.ui.share.model.api.property.Property import com.nextcloud.android.common.ui.share.model.api.property.PropertyBoolean import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate import com.nextcloud.android.common.ui.share.model.api.property.PropertyEnum import com.nextcloud.android.common.ui.share.model.api.property.PropertyPassword import com.nextcloud.android.common.ui.share.model.api.property.PropertyString -import kotlinx.coroutines.delay +import com.nextcloud.android.common.ui.share.model.api.property.clazz @Composable fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewModel) { + val propertyErrors by viewModel.propertyErrors.collectAsState() + val hasError = propertyErrors[property.clazz] != null + when (property) { is PropertyBoolean -> { var checkedValue by remember(property.clazz) { mutableStateOf(property.isTrue()) } - ShareSwitch( - label = property.displayName, - checked = checkedValue, - onCheckedChange = { isChecked -> - checkedValue = isChecked - viewModel.updatePropertyLocally(property.clazz, isChecked.toString()) + Column { + ShareSwitch( + label = property.displayName, + checked = checkedValue, + onCheckedChange = { isChecked -> + checkedValue = isChecked + viewModel.updatePropertyDebounced(shareId, property.clazz, isChecked.toString()) + } + ) + if (hasError) { + Text( + text = stringResource(R.string.share_view_update_error_message), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp) + ) } - ) + } } is PropertyString -> { var textValue by remember(property.clazz) { mutableStateOf(property.value ?: "") } - DebouncedPropertyUpdater( - value = textValue, - originalValue = property.value ?: "", - onDebounceComplete = { viewModel.updatePropertyLocally(property.clazz, it) } - ) - OutlinedTextField( value = textValue, - onValueChange = { textValue = it }, + onValueChange = { + textValue = it + viewModel.updatePropertyDebounced(shareId, property.clazz, it) + }, label = { Text(property.displayName) }, + isError = hasError, + supportingText = if (hasError) { + { Text(stringResource(R.string.share_view_update_error_message)) } + } else { + null + }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), singleLine = true ) @@ -69,18 +89,21 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM is PropertyPassword -> { var passwordValue by remember(property.clazz) { mutableStateOf(property.value ?: "") } - DebouncedPropertyUpdater( - value = passwordValue, - originalValue = property.value ?: "", - onDebounceComplete = { viewModel.updatePropertyLocally(property.clazz, it) } - ) - OutlinedTextField( value = passwordValue, - onValueChange = { passwordValue = it }, + onValueChange = { + passwordValue = it + viewModel.updatePropertyDebounced(shareId, property.clazz, it) + }, label = { Text(property.displayName) }, placeholder = property.hint?.let { { Text(it) } }, visualTransformation = PasswordVisualTransformation(), + isError = hasError, + supportingText = if (hasError) { + { Text(stringResource(R.string.share_view_update_error_message)) } + } else { + null + }, modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), @@ -95,24 +118,7 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM } is PropertyEnum -> { - // TODO: Implement ExposedDropdownMenuBox using property.validValues Text(text = "Enum Property: ${property.displayName} (Under Construction)", color = Color.Gray) } } } - -@Composable -private fun DebouncedPropertyUpdater( - value: String, - originalValue: String, - delayMillis: Long = 400L, - onDebounceComplete: (String) -> Unit -) { - LaunchedEffect(value) { - if (value != originalValue) { - delay(delayMillis) - onDebounceComplete(value) - } - } -} - From 522cc85b60460153b85fb727d4d4a4f4d943e490 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 15:47:01 +0200 Subject: [PATCH 54/95] add anyone recipient Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 20 +++++++++++++++++++ .../bottomsheet/AddOrEditShareBottomSheet.kt | 9 ++++----- .../share/model/api/recipients/Recipient.kt | 6 +++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 31376752..b7d9b633 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -20,6 +20,7 @@ import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareProper import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.ui.ShareCategory import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState import com.nextcloud.android.common.ui.share.model.ui.filtered import com.nextcloud.android.common.ui.share.repository.ShareRepository @@ -38,6 +39,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.UUID class ShareViewModel( private val repository: ShareRepository @@ -193,6 +195,24 @@ class ShareViewModel( } } + fun addAnyoneRecipient(category: ShareCategory, share: Share) { + if (category != ShareCategory.Anyone) { + return + } + + val alreadyAdded = share.recipients.any { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS } + if (alreadyAdded) { + return + } + + addRecipient( + id = share.id, + clazz = Recipient.TOKEN_RECIPIENT_CLASS, + value = UUID.randomUUID().toString(), + instance = UUID.randomUUID().toString() + ) + } + fun removeRecipient(id: String, clazz: String, value: String, instance: String? = null) { viewModelScope.launch(Dispatchers.IO) { val result = repository.removeShareRecipient(id, clazz, value, instance) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 09b2b6b6..9929ddc7 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -96,7 +96,10 @@ fun AddOrEditShareBottomSheet( CategorySelector( categories = categories, selectedCategory = selectedCategory, - onCategorySelected = { selectedCategory = it } + onCategorySelected = { category -> + selectedCategory = category + viewModel.addAnyoneRecipient(category, share) + } ) if (selectedCategory == ShareCategory.Invited) { @@ -227,15 +230,11 @@ private fun CategorySelector( } } -// TODO implement Anyone --> /ocs/v2.php/apps/sharing/api/v1/share/{id}/recipient -// --> Add a recipient to a share use correspongind class value --> doesnt matter use uuid string : "value": "string", "instance": "string" // TODO discuss with laura for note to recepipent expiraitaion date proeprties etc should be collabls // TODO for COPY link use Recepient.Secret.URL side note: if value is updatable user can edit the TOKEN directly which is value -// TODO display error messages and handle them via debounce mechanism - // TODO fetch shares currently returns all shares SOURCE ID will be implemented so that u can only show related shares // TODO show share icon from first recipient but check with laura diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt index 5f900004..a8e21e35 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt @@ -27,4 +27,8 @@ data class Recipient( val icon: Icon? = null, val secret: Secret -) +) { + companion object { + const val TOKEN_RECIPIENT_CLASS = "OC\\CoreSharing\\Recipient\\TokenShareRecipientType" + } +} From 02f54bcb250761f592b28ff72a331dafb180fbe1 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 15:59:00 +0200 Subject: [PATCH 55/95] better error handling Signed-off-by: alperozturk96 --- .../ui/network/http/NextcloudHttpClient.kt | 9 ++- .../common/ui/network/model/NetworkResult.kt | 10 ++++ .../android/common/ui/share/ShareViewModel.kt | 59 ++++++++++--------- 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt index 8996bfb2..5316ea0b 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt @@ -10,10 +10,12 @@ package com.nextcloud.android.common.ui.network.http import com.nextcloud.android.common.ui.network.auth.AuthInterceptor import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.model.NetworkResult +import com.nextcloud.android.common.ui.network.model.Ocs import com.nextcloud.android.common.ui.network.model.OcsResponse import com.nextcloud.android.common.ui.network.serialization.OCSSerializer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonElement import okhttp3.OkHttpClient import okhttp3.RequestBody import okhttp3.logging.HttpLoggingInterceptor @@ -65,7 +67,12 @@ class NextcloudHttpClient private constructor( val response = okHttpClient.newCall(request).execute() val responseBody = response.body.string() if (response.isSuccessful) { - NetworkResult.Success(parse(responseBody)) + val envelope = OCSSerializer.json.decodeFromString>(responseBody) + if (envelope.ocs.meta.status != "ok") { + NetworkResult.ServerError(OcsResponse(Ocs(envelope.ocs.meta, envelope.ocs.meta.message))) + } else { + NetworkResult.Success(parse(responseBody)) + } } else { NetworkResult.ServerError(OCSSerializer.json.decodeFromString>(responseBody)) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt index 932761b6..ef05856b 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt @@ -20,3 +20,13 @@ sealed class NetworkResult { fun fromException(e: Throwable): NetworkException = NetworkException(e) } } + +inline fun NetworkResult.dataOrElse(onError: () -> Unit): T? = + when (this) { + is NetworkResult.Success -> data + is NetworkResult.ServerError, + is NetworkResult.NetworkException -> { + onError() + null + } + } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index b7d9b633..29ad7c4f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.network.model.NetworkResult +import com.nextcloud.android.common.ui.network.model.dataOrElse import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest @@ -40,11 +41,17 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds class ShareViewModel( private val repository: ShareRepository ) : ViewModel() { + companion object { + private const val SEARCH_DEBOUNCE_DELAY_MS = 300L + private const val PROPERTY_DEBOUNCE_DELAY_MS = 1000L + } + private val _state = MutableStateFlow(ShareScreenState.Loading) val state: StateFlow = _state @@ -79,7 +86,7 @@ class ShareViewModel( private fun initSearchQuery() { viewModelScope.launch { _searchQuery - .debounce(300L) + .debounce(SEARCH_DEBOUNCE_DELAY_MS.milliseconds) .distinctUntilChanged() .filter { it.isNotBlank() } .collect { query -> executeSearch(query) } @@ -109,7 +116,8 @@ class ShareViewModel( _errorMessageId.update { null } val result = repository.fetchShares(sourceClass, lastShareID, limit) - val fetched = handleResult(result, R.string.share_view_fetch_error_message) ?: return@launch + val fetched = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } + ?: return@launch _state.update { if (fetched.filtered().isEmpty()) ShareScreenState.Empty else ShareScreenState.Loaded(fetched) @@ -122,7 +130,8 @@ class ShareViewModel( _errorMessageId.update { null } val result = repository.fetchShare(id, request) - val share = handleResult(result, R.string.share_view_fetch_error_message) ?: return@launch + val share = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } + ?: return@launch _activeShare.update { share } replaceInList(share) @@ -135,7 +144,8 @@ class ShareViewModel( _errorMessageId.update { null } val result = repository.createDraftShare() - val draft = handleResult(result, R.string.share_view_create_error_message) + + val draft = result.dataOrElse { _errorMessageId.update { R.string.share_view_create_error_message } } if (draft != null) { _activeShare.update { draft } @@ -153,7 +163,8 @@ class ShareViewModel( val result = withContext(Dispatchers.IO) { repository.updateShareState(id, UpdateShareStateRequest(shareState)) } - val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch if (shareState == ShareState.ACTIVE) { _activeShare.update { null } } else { @@ -169,7 +180,8 @@ class ShareViewModel( viewModelScope.launch(Dispatchers.IO) { val clazz = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType" val result = repository.addShareSource(id, AddSourceRequest(clazz, value)) - val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch _activeShare.update { updated } replaceInList(updated) } @@ -178,7 +190,8 @@ class ShareViewModel( fun removeSource(id: String, clazz: String, value: String) { viewModelScope.launch(Dispatchers.IO) { val result = repository.removeShareSource(id, clazz, value) - val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch _activeShare.update { updated } replaceInList(updated) } @@ -189,7 +202,8 @@ class ShareViewModel( fun addRecipient(id: String, clazz: String, value: String, instance: String? = null) { viewModelScope.launch(Dispatchers.IO) { val result = repository.addShareRecipient(id, AddRecipientRequest(clazz, value, instance)) - val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch _activeShare.update { updated } replaceInList(updated) } @@ -216,7 +230,8 @@ class ShareViewModel( fun removeRecipient(id: String, clazz: String, value: String, instance: String? = null) { viewModelScope.launch(Dispatchers.IO) { val result = repository.removeShareRecipient(id, clazz, value, instance) - val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch _activeShare.update { updated } replaceInList(updated) } @@ -227,7 +242,8 @@ class ShareViewModel( fun updateProperty(id: String, clazz: String, value: String?) { viewModelScope.launch(Dispatchers.IO) { val result = repository.updateShareProperty(id, UpdateSharePropertyRequest(clazz, value)) - val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch _activeShare.update { updated } replaceInList(updated) } @@ -236,7 +252,7 @@ class ShareViewModel( fun updatePropertyDebounced(shareId: String, clazz: String, value: String) { pendingPropertyJobs[clazz]?.job?.cancel() val job = viewModelScope.launch { - delay(PROPERTY_DEBOUNCE_DELAY_MS) + delay(PROPERTY_DEBOUNCE_DELAY_MS.milliseconds) pendingPropertyJobs.remove(clazz) withContext(Dispatchers.IO) { executePropertyUpdate(shareId, clazz, value) } } @@ -264,6 +280,7 @@ class ShareViewModel( _activeShare.update { result.data } replaceInList(result.data) } + is NetworkResult.ServerError, is NetworkResult.NetworkException -> { _propertyErrors.update { it + (clazz to R.string.share_view_update_error_message) } @@ -276,7 +293,8 @@ class ShareViewModel( fun updatePermission(id: String, clazz: String, enabled: Boolean) { viewModelScope.launch(Dispatchers.IO) { val result = repository.updateSharePermission(id, UpdateSharePermissionRequest(clazz, enabled)) - val updated = handleResult(result, R.string.share_view_update_error_message) ?: return@launch + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch _activeShare.update { updated } replaceInList(updated) } @@ -287,7 +305,7 @@ class ShareViewModel( fun deleteShare(id: String) { viewModelScope.launch(Dispatchers.IO) { val result = repository.deleteShare(id) - handleResult(result, R.string.share_view_delete_error_message) ?: return@launch + result.dataOrElse { _errorMessageId.update { R.string.share_view_delete_error_message } } ?: return@launch val remaining = currentShares.filterNot { it.id == id } _state.update { @@ -311,24 +329,9 @@ class ShareViewModel( // endregion // region private - companion object { - private const val PROPERTY_DEBOUNCE_DELAY_MS = 1000L - } - private fun replaceInList(updated: Share) { val shares = currentShares.ifEmpty { return } _state.update { ShareScreenState.Loaded(shares.map { if (it.id == updated.id) updated else it }) } } - - private fun handleResult(result: NetworkResult, errorId: Int): T? { - return when (result) { - is NetworkResult.Success -> result.data - is NetworkResult.ServerError, - is NetworkResult.NetworkException -> { - _errorMessageId.update { errorId } - null - } - } - } // endregion } From f47222d93eb2e126b5255d289fdb773c6b8e65a1 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 16:11:43 +0200 Subject: [PATCH 56/95] better error handling Signed-off-by: alperozturk96 --- .../com/nextcloud/android/common/ui/share/ShareViewModel.kt | 4 +++- .../android/common/ui/share/model/api/recipients/Recipient.kt | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 29ad7c4f..b3ec6b95 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -219,11 +219,13 @@ class ShareViewModel( return } + // TODO: other recipients if user switch to anyone. + // THIS WILL FAIL for now UUID.randomUUID().toString() will be supported addRecipient( id = share.id, clazz = Recipient.TOKEN_RECIPIENT_CLASS, value = UUID.randomUUID().toString(), - instance = UUID.randomUUID().toString() + instance = null ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt index a8e21e35..be1ce60a 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt @@ -29,6 +29,6 @@ data class Recipient( val secret: Secret ) { companion object { - const val TOKEN_RECIPIENT_CLASS = "OC\\CoreSharing\\Recipient\\TokenShareRecipientType" + const val TOKEN_RECIPIENT_CLASS = "OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType" } } From 1129c28a8ff636708b741248ae9b0d6922ad8ac3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 16:47:17 +0200 Subject: [PATCH 57/95] fix issues Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 1 + .../bottomsheet/AddOrEditShareBottomSheet.kt | 64 ++++---- .../component/property/SharePropertyView.kt | 1 - .../ui/share/model/api/property/Property.kt | 144 ++++++------------ .../common/ui/share/model/api/share/Share.kt | 10 +- 5 files changed, 84 insertions(+), 136 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index b3ec6b95..c12c1e9d 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -178,6 +178,7 @@ class ShareViewModel( // region sources fun addSource(id: String, value: String) { viewModelScope.launch(Dispatchers.IO) { + // TODO pass from the clients this may vary depends on the client so notes and files for now uses this val clazz = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType" val result = repository.addShareSource(id, AddSourceRequest(clazz, value)) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 9929ddc7..8d476ea1 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -50,8 +50,6 @@ import com.nextcloud.android.common.ui.share.component.RecipientSearchField import com.nextcloud.android.common.ui.share.component.ShareSwitch import com.nextcloud.android.common.ui.share.component.property.SharePropertyView import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities -import com.nextcloud.android.common.ui.share.model.api.property.clazz -import com.nextcloud.android.common.ui.share.model.api.property.priority import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.model.ui.ShareCategory @@ -130,16 +128,7 @@ fun AddOrEditShareBottomSheet( } if (share.readyToSend()) { - val localClipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - - Buttons(onCopyLink = { - scope.launch { - share.getClipEntry()?.let { clipEntry -> - localClipboard.setClipEntry(clipEntry) - } - } - }, onSend = { + Buttons(share, selectedCategory, onSend = { viewModel.updateState(share.id, ShareState.ACTIVE) }) } @@ -149,7 +138,8 @@ fun AddOrEditShareBottomSheet( @Composable private fun Buttons( - onCopyLink: () -> Unit = {}, + share: Share, + category: ShareCategory, onSend: () -> Unit = {}, ) { Row( @@ -158,26 +148,37 @@ private fun Buttons( .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Button( - onClick = onCopyLink, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon( - painter = painterResource(R.drawable.ic_link), - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource(R.string.share_view_copy_action), - modifier = Modifier.padding(start = 8.dp), - ) + val clipEntry = share.getClipEntry() + if (category == ShareCategory.Anyone && clipEntry != null) { + val localClipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + Button( + onClick = { + scope.launch { + localClipboard.setClipEntry(clipEntry) + } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_link), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.share_view_copy_action), + modifier = Modifier.padding(start = 8.dp), + ) + } } + Button( onClick = onSend, modifier = Modifier.weight(1f), @@ -230,6 +231,7 @@ private fun CategorySelector( } } +// TODO copy link must be only for ANYONE // TODO discuss with laura for note to recepipent expiraitaion date proeprties etc should be collabls diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt index 0ad0c739..ca482a15 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt @@ -34,7 +34,6 @@ import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate import com.nextcloud.android.common.ui.share.model.api.property.PropertyEnum import com.nextcloud.android.common.ui.share.model.api.property.PropertyPassword import com.nextcloud.android.common.ui.share.model.api.property.PropertyString -import com.nextcloud.android.common.ui.share.model.api.property.clazz @Composable fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewModel) { diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt index 2aeb3d8c..27522128 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt @@ -15,129 +15,73 @@ import kotlinx.serialization.json.JsonClassDiscriminator @OptIn(ExperimentalSerializationApi::class) @JsonClassDiscriminator("type") @Serializable -sealed class Property - -val Property.priority: Int - get() = when (this) { - is PropertyBoolean -> priority - is PropertyDate -> priority - is PropertyEnum -> priority - is PropertyPassword -> priority - is PropertyString -> priority - } - -val Property.clazz: String - get() = when (this) { - is PropertyBoolean -> clazz - is PropertyDate -> clazz - is PropertyEnum -> clazz - is PropertyPassword -> clazz - is PropertyString -> clazz - } +sealed class Property { + abstract val clazz: String + abstract val displayName: String + abstract val hint: String? + abstract val priority: Int + abstract val required: Boolean + abstract val value: String? +} @Serializable @SerialName("boolean") data class PropertyBoolean( - @SerialName("class") - val clazz: String, - - @SerialName("display_name") - val displayName: String, - - val hint: String? = null, - - val priority: Int, - - val required: Boolean, - - val value: String? = null + @SerialName("class") override val clazz: String, + @SerialName("display_name") override val displayName: String, + override val hint: String? = null, + override val priority: Int, + override val required: Boolean, + override val value: String? = null ) : Property() { - fun isTrue(): Boolean = (value == "true") + fun isTrue(): Boolean = value == "true" } @Serializable @SerialName("date") data class PropertyDate( - @SerialName("class") - val clazz: String, - - @SerialName("display_name") - val displayName: String, - - val hint: String? = null, - - val priority: Int, - - val required: Boolean, - - val value: String? = null, - - @SerialName("min_date") - val minDate: String? = null, - - @SerialName("max_date") - val maxDate: String? = null + @SerialName("class") override val clazz: String, + @SerialName("display_name") override val displayName: String, + override val hint: String? = null, + override val priority: Int, + override val required: Boolean, + override val value: String? = null, + @SerialName("min_date") val minDate: String? = null, + @SerialName("max_date") val maxDate: String? = null ) : Property() @Serializable @SerialName("enum") data class PropertyEnum( - @SerialName("class") - val clazz: String, - - @SerialName("display_name") - val displayName: String, - - val hint: String? = null, - - val priority: Int, - - val required: Boolean, - - val value: String? = null, - - @SerialName("valid_values") - val validValues: List + @SerialName("class") override val clazz: String, + @SerialName("display_name") override val displayName: String, + override val hint: String? = null, + override val priority: Int, + override val required: Boolean, + override val value: String? = null, + @SerialName("valid_values") val validValues: List ) : Property() @Serializable @SerialName("password") data class PropertyPassword( - @SerialName("class") - val clazz: String, - - @SerialName("display_name") - val displayName: String, - - val hint: String? = null, - - val priority: Int, - - val required: Boolean, - - val value: String? = null + @SerialName("class") override val clazz: String, + @SerialName("display_name") override val displayName: String, + override val hint: String? = null, + override val priority: Int, + override val required: Boolean, + override val value: String? = null ) : Property() @Serializable @SerialName("string") data class PropertyString( - @SerialName("class") - val clazz: String, - - @SerialName("display_name") - val displayName: String, - - val hint: String? = null, - - val priority: Int, - - val required: Boolean, - - val value: String? = null, - - @SerialName("min_length") - val minLength: Int? = null, - - @SerialName("max_length") - val maxLength: Int? = null + @SerialName("class") override val clazz: String, + @SerialName("display_name") override val displayName: String, + override val hint: String? = null, + override val priority: Int, + override val required: Boolean, + override val value: String? = null, + @SerialName("min_length") val minLength: Int? = null, + @SerialName("max_length") val maxLength: Int? = null ) : Property() diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 1d8b473e..9da0e6d6 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -43,7 +43,7 @@ data class Share( ) { fun getClipEntry(): ClipEntry? { val label = recipients.first().displayName - val link = recipients.first().secret.url + val link = recipients.firstOrNull()?.secret?.url return if (link != null) { ClipData.newPlainText(label, link).toClipEntry() @@ -52,9 +52,11 @@ data class Share( } } - fun readyToSend(): Boolean { - return sources.isNotEmpty() && recipients.isNotEmpty() && permissions.any { it.enabled } - } + fun readyToSend(): Boolean = + sources.isNotEmpty() && + recipients.isNotEmpty() && + permissions.any { it.enabled } && + properties.none { it.required && it.value.isNullOrEmpty() } fun title(context: Context): String { return if (shareState == ShareState.DRAFT) { From 0413f26d5354ece7b982b8b95f0a378e4b0e9840 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 16:52:21 +0200 Subject: [PATCH 58/95] fix issues Signed-off-by: alperozturk96 --- .../ui/network/http/NextcloudHttpClient.kt | 3 +- .../android/common/ui/share/ShareViewModel.kt | 29 +++++++++---------- .../share/model/api/recipients/Recipient.kt | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt index 5316ea0b..5294b8fd 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt @@ -29,6 +29,7 @@ class NextcloudHttpClient private constructor( private const val CONNECT_TIMEOUT_SECONDS = 90L private const val READ_TIMEOUT_SECONDS = 90L private const val WRITE_TIMEOUT_SECONDS = 90L + private const val OCS_OK = "ok" fun create( credentials: ServerCredentials, @@ -68,7 +69,7 @@ class NextcloudHttpClient private constructor( val responseBody = response.body.string() if (response.isSuccessful) { val envelope = OCSSerializer.json.decodeFromString>(responseBody) - if (envelope.ocs.meta.status != "ok") { + if (envelope.ocs.meta.status != OCS_OK) { NetworkResult.ServerError(OcsResponse(Ocs(envelope.ocs.meta, envelope.ocs.meta.message))) } else { NetworkResult.Success(parse(responseBody)) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index c12c1e9d..91d38556 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -211,23 +211,22 @@ class ShareViewModel( } fun addAnyoneRecipient(category: ShareCategory, share: Share) { - if (category != ShareCategory.Anyone) { - return - } + if (category != ShareCategory.Anyone) return - val alreadyAdded = share.recipients.any { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS } - if (alreadyAdded) { - return - } + share.recipients + .filter { it.clazz != Recipient.TOKEN_RECIPIENT_CLASS } + .forEach { recipient -> + removeRecipient(share.id, recipient.clazz, recipient.value, recipient.instance) + } - // TODO: other recipients if user switch to anyone. - // THIS WILL FAIL for now UUID.randomUUID().toString() will be supported - addRecipient( - id = share.id, - clazz = Recipient.TOKEN_RECIPIENT_CLASS, - value = UUID.randomUUID().toString(), - instance = null - ) + if (share.recipients.none { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS }) { + addRecipient( + id = share.id, + clazz = Recipient.TOKEN_RECIPIENT_CLASS, + value = UUID.randomUUID().toString(), + instance = null + ) + } } fun removeRecipient(id: String, clazz: String, value: String, instance: String? = null) { diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt index be1ce60a..a8e21e35 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt @@ -29,6 +29,6 @@ data class Recipient( val secret: Secret ) { companion object { - const val TOKEN_RECIPIENT_CLASS = "OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType" + const val TOKEN_RECIPIENT_CLASS = "OC\\CoreSharing\\Recipient\\TokenShareRecipientType" } } From 12bf9357a9fb8e452908d3ef224c6e5795abb03b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 22 Jun 2026 11:55:00 +0200 Subject: [PATCH 59/95] wip Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 8d476ea1..7a9e16f8 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -231,18 +231,7 @@ private fun CategorySelector( } } -// TODO copy link must be only for ANYONE -// TODO discuss with laura for note to recepipent expiraitaion date proeprties etc should be collabls - -// TODO for COPY link use Recepient.Secret.URL side note: if value is updatable user can edit the TOKEN directly which is value - -// TODO fetch shares currently returns all shares SOURCE ID will be implemented so that u can only show related shares - -// TODO show share icon from first recipient but check with laura - -// TODO: Backend will change and provide us bundled permissions or list of custom permissions. -// USE THIS FOR BOTH OPTION @Composable private fun PermissionCategories( share: Share, From d728b1a1ce21d34a603b0014ad5bd08150f84373 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 13:51:52 +0200 Subject: [PATCH 60/95] wip Signed-off-by: alperozturk96 --- gradle/verification-metadata.xml | 12 ++++++++++++ sample/build.gradle | 1 + ui/build.gradle | 18 +++++++++++------- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index bf3afce2..a68fd3cb 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -98,6 +98,7 @@ + @@ -251,6 +252,7 @@ + @@ -19374,6 +19376,11 @@ + + + + + @@ -20869,6 +20876,11 @@ + + + + + diff --git a/sample/build.gradle b/sample/build.gradle index 72fd3597..f5fc90ba 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -45,6 +45,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.11.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' implementation project(path: ':ui') testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.3.0' diff --git a/ui/build.gradle b/ui/build.gradle index cc6fecfe..20ce363b 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -16,7 +16,7 @@ plugins { android { defaultConfig { - minSdk = 21 + minSdk = 26 compileSdk = 37 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -46,8 +46,9 @@ android { } dependencies { - implementation 'androidx.compose.ui:ui-tooling-preview:1.11.0' - debugImplementation 'androidx.compose.ui:ui-tooling:1.11.0' + // region Jetpack Compose + implementation 'androidx.compose.ui:ui-tooling-preview:1.11.4' + debugImplementation 'androidx.compose.ui:ui-tooling:1.11.4' kapt "org.jetbrains.kotlin:kotlin-metadata-jvm:${kotlinVersion}" implementation(platform("androidx.compose:compose-bom:2026.06.01")) @@ -55,10 +56,11 @@ dependencies { implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-core") + // endregion - implementation("io.coil-kt.coil3:coil-compose:3.4.0") - implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") - implementation("io.coil-kt.coil3:coil-svg:3.4.0") + implementation("io.coil-kt.coil3:coil-compose:3.5.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0") + implementation("io.coil-kt.coil3:coil-svg:3.5.0") implementation("com.vanniktech:ui:0.10.0") @@ -68,9 +70,11 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' - implementation(platform("com.squareup.okhttp3:okhttp-bom:5.3.2")) + // region okhttp + implementation(platform("com.squareup.okhttp3:okhttp-bom:5.4.0")) implementation("com.squareup.okhttp3:okhttp") implementation("com.squareup.okhttp3:logging-interceptor") + // endregion implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") From 2d8d41f4887e8897398ecaceaf7a09076f35a205 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 15:00:14 +0200 Subject: [PATCH 61/95] wip Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 58 ++++--------------- .../capabilities/PermissionCategoryType.kt | 23 -------- .../api/capabilities/SharingCapabilities.kt | 2 - .../common/ui/share/model/api/owner/Owner.kt | 25 -------- .../share/model/api/permission/Permission.kt | 2 +- .../share/model/api/recipients/Recipient.kt | 5 +- .../common/ui/share/model/api/share/Share.kt | 10 +++- .../share/repository/MockShareRepository.kt | 21 +++---- 8 files changed, 35 insertions(+), 111 deletions(-) delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/PermissionCategoryType.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 7a9e16f8..d6ff084e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -240,56 +240,22 @@ private fun PermissionCategories( onToggleCategory: (String) -> Unit, viewModel: ShareViewModel ) { - /* - var expanded by remember { mutableStateOf(false) } + // We have 3 options in dropdown null, edit, view + // If user selects null ("Can...") just show all permissions without hiding behind collabsection section + // If user selects edit or view dont show any toggle -> immediately update permission - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - share.permissions.forEach { - key(it.displayName) { - DropdownMenuItem( - text = { Text(it.displayName) }, - onClick = { /* Do something... */ } + share.permissions + .forEach { permission -> + key(permission.clazz) { + ShareSwitch( + label = permission.displayName, + checked = permission.enabled, + onCheckedChange = { isChecked -> + viewModel.updatePermission(share.id, permission.clazz, isChecked) + } ) } } - - DropdownMenuItem( - text = { Text("Option 2") }, - onClick = { /* Do something... */ } - ) - } - */ - - - sharingCapabilities.permissionCategoryTypes - .sortedBy { it.priority } - .forEach { sharingCapability -> - key(sharingCapability.classField) { - CollapsibleShareSection( - label = sharingCapability.displayName, - isExpanded = sharingCapability.displayName in expandedCategories, - onToggle = { onToggleCategory(sharingCapability.displayName) }, - ) { - share.permissions - .filter { permission -> permission.category == sharingCapability.classField } - .sortedBy { it.displayName } - .forEach { permission -> - key(permission.clazz) { - ShareSwitch( - label = permission.displayName, - checked = permission.enabled, - onCheckedChange = { isChecked -> - viewModel.updatePermission(share.id, permission.clazz, isChecked) - } - ) - } - } - } - } - } } @Composable diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/PermissionCategoryType.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/PermissionCategoryType.kt deleted file mode 100644 index 17fd74cc..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/PermissionCategoryType.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.model.api.capabilities - -import com.nextcloud.android.common.ui.share.model.api.icon.Icon -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class PermissionCategoryType( - @SerialName("class") - val classField: String, - @SerialName("display_name") - val displayName: String, - val hint: String? = null, - val icon: Icon? = null, - val priority: Long, -) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt index 13452ea9..04eb864d 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt @@ -17,6 +17,4 @@ data class SharingCapabilities( val legacy: Legacy? = null, @SerialName("source_types") val sourceTypes: List, - @SerialName("permission_category_types") - val permissionCategoryTypes: List, ) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt deleted file mode 100644 index d5646004..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.model.api.owner - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import com.nextcloud.android.common.ui.share.model.api.icon.Icon - -@Serializable -data class Owner( - @SerialName("user_id") - val userId: String, - - val instance: String? = null, - - @SerialName("display_name") - val displayName: String, - - val icon: Icon -) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt index 1e8c62f9..d568ae08 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt @@ -23,7 +23,7 @@ data class Permission( val hint: String? = null, - val category: String? = null, + val presets: List, val enabled: Boolean ) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt index a8e21e35..5d250b31 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt @@ -9,6 +9,7 @@ package com.nextcloud.android.common.ui.share.model.api.recipients import com.nextcloud.android.common.ui.share.model.api.icon.Icon import com.nextcloud.android.common.ui.share.model.api.secret.Secret +import com.nextcloud.android.common.ui.share.model.api.user.User import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -26,7 +27,9 @@ data class Recipient( val icon: Icon? = null, - val secret: Secret + val secret: Secret, + + val initiator: User? = null ) { companion object { const val TOKEN_RECIPIENT_CLASS = "OC\\CoreSharing\\Recipient\\TokenShareRecipientType" diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 9da0e6d6..435c20f5 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -12,12 +12,13 @@ import android.content.Context import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.toClipEntry import com.nextcloud.android.common.ui.R -import com.nextcloud.android.common.ui.share.model.api.owner.Owner import com.nextcloud.android.common.ui.share.model.api.permission.Permission +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset import com.nextcloud.android.common.ui.share.model.api.property.Property import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.source.Source import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.api.user.User import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -25,7 +26,7 @@ import kotlinx.serialization.Serializable data class Share( val id: String, - val owner: Owner, + val owner: User, @SerialName("last_updated") val lastUpdated: Long, @@ -39,7 +40,10 @@ data class Share( val properties: List, - val permissions: List + val permissions: List, + + @SerialName("permission_preset") + val permissionPreset: PermissionPreset? = null ) { fun getClipEntry(): ClipEntry? { val label = recipients.first().displayName diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt index 2edf350e..f2f464b8 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -9,8 +9,8 @@ package com.nextcloud.android.common.ui.share.repository import com.nextcloud.android.common.ui.network.model.NetworkResult import com.nextcloud.android.common.ui.share.model.api.icon.Icon -import com.nextcloud.android.common.ui.share.model.api.owner.Owner import com.nextcloud.android.common.ui.share.model.api.permission.Permission +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset import com.nextcloud.android.common.ui.share.model.api.property.PropertyBoolean import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate import com.nextcloud.android.common.ui.share.model.api.property.PropertyPassword @@ -27,10 +27,11 @@ import com.nextcloud.android.common.ui.share.model.api.secret.Secret import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.source.Source import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.api.user.User class MockShareRepository : ShareRepository { - private val mockOwner = Owner( + private val mockOwner = User( userId = "alice", displayName = "Alice Johnson", icon = Icon( @@ -43,19 +44,19 @@ class MockShareRepository : ShareRepository { Permission( clazz = "read", displayName = "Read", - category = "access", + presets = listOf(PermissionPreset.VIEW, PermissionPreset.EDIT), enabled = true ), Permission( clazz = "update", displayName = "Update", - category = "access", + presets = listOf(PermissionPreset.EDIT), enabled = false ), Permission( clazz = "delete", displayName = "Delete", - category = "access", + presets = listOf(PermissionPreset.EDIT), enabled = false ) ) @@ -99,7 +100,7 @@ class MockShareRepository : ShareRepository { recipients: List, shareState: ShareState = ShareState.ACTIVE, lastUpdated: Long = System.currentTimeMillis(), - owner: Owner = mockOwner + owner: User = mockOwner ) = Share( id = id, owner = owner, @@ -147,7 +148,7 @@ class MockShareRepository : ShareRepository { secret = Secret(false, value = "", url = "") ) ), - owner = Owner( + owner = User( userId = "system", displayName = "System", icon = Icon( @@ -163,7 +164,7 @@ class MockShareRepository : ShareRepository { ), recipients = emptyList(), lastUpdated = 1710000000L, - owner = Owner( + owner = User( userId = "system", displayName = "System", icon = Icon( @@ -189,7 +190,7 @@ class MockShareRepository : ShareRepository { secret = Secret(false, value = "", url = "") ) ), - owner = Owner( + owner = User( userId = "john", displayName = "John External", icon = Icon( @@ -216,7 +217,7 @@ class MockShareRepository : ShareRepository { secret = Secret(false, value = "", url = "") ) ), - owner = Owner( + owner = User( userId = "partner", displayName = "Partner Cloud", icon = Icon( From a71cf5c807d77815cddf15c8d168c28c06ac311e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 15:00:22 +0200 Subject: [PATCH 62/95] wip Signed-off-by: alperozturk96 --- .../model/api/permission/PermissionPreset.kt | 20 +++++++++++++++ .../common/ui/share/model/api/user/User.kt | 25 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/PermissionPreset.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/User.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/PermissionPreset.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/PermissionPreset.kt new file mode 100644 index 00000000..fb4f321c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/PermissionPreset.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.permission + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class PermissionPreset { + @SerialName("view") + VIEW, + + @SerialName("edit") + EDIT +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/User.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/User.kt new file mode 100644 index 00000000..d0870bf3 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/User.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.user + +import com.nextcloud.android.common.ui.share.model.api.icon.Icon +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class User( + @SerialName("user_id") + val userId: String, + + val instance: String? = null, + + @SerialName("display_name") + val displayName: String, + + val icon: Icon +) From 018d3d75c5bace60b9ecb76ceeaa50deb8de638e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 15:14:09 +0200 Subject: [PATCH 63/95] add PermissionPresetDropdown Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 1 - .../android/common/ui/share/ShareViewModel.kt | 12 +++ .../bottomsheet/AddOrEditShareBottomSheet.kt | 91 +++++++++++++------ .../share/model/api/request/ShareRequests.kt | 6 ++ .../share/model/ui/PermissionPresetOption.kt | 25 +++++ .../share/repository/MockShareRepository.kt | 16 ++++ .../share/repository/ShareRemoteRepository.kt | 13 +++ .../ui/share/repository/ShareRepository.kt | 6 ++ ui/src/main/res/values/strings.xml | 5 + 9 files changed, 148 insertions(+), 27 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOption.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index d49523c0..ccd48760 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -155,7 +155,6 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti activeShare?.let { AddOrEditShareBottomSheet( share = it, - sharingCapabilities = sharingCapabilities, viewModel = viewModel, onDismissDraft = { draftShare -> activeShare?.let { viewModel.deleteShare(draftShare.id) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 91d38556..ec6992cb 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -16,6 +16,8 @@ import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionPresetRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest @@ -301,6 +303,16 @@ class ShareViewModel( replaceInList(updated) } } + + fun updatePermissionPreset(id: String, preset: PermissionPreset) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.updateSharePermissionPreset(id, UpdateSharePermissionPresetRequest(preset)) + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + _activeShare.update { updated } + replaceInList(updated) + } + } // endregion // region delete diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index d6ff084e..08c247f4 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -21,10 +21,15 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow @@ -49,9 +54,9 @@ import com.nextcloud.android.common.ui.share.component.CollapsibleShareSection import com.nextcloud.android.common.ui.share.component.RecipientSearchField import com.nextcloud.android.common.ui.share.component.ShareSwitch import com.nextcloud.android.common.ui.share.component.property.SharePropertyView -import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption import com.nextcloud.android.common.ui.share.model.ui.ShareCategory import kotlinx.coroutines.launch @@ -59,7 +64,6 @@ import kotlinx.coroutines.launch @Composable fun AddOrEditShareBottomSheet( share: Share, - sharingCapabilities: SharingCapabilities, viewModel: ShareViewModel, onDismissDraft: (Share) -> Unit = {} ) { @@ -68,7 +72,6 @@ fun AddOrEditShareBottomSheet( val categories = remember { ShareCategory.entries.toList() } var selectedCategory by remember { mutableStateOf(categories.first()) } var showAdvancedSettings by remember { mutableStateOf(false) } - var expandedCategories by remember { mutableStateOf(emptySet()) } ModalBottomSheet( onDismissRequest = { @@ -106,15 +109,6 @@ fun AddOrEditShareBottomSheet( PermissionCategories( share = share, - sharingCapabilities = sharingCapabilities, - expandedCategories = expandedCategories, - onToggleCategory = { categoryName -> - expandedCategories = if (expandedCategories.contains(categoryName)) { - expandedCategories - categoryName - } else { - expandedCategories + categoryName - } - }, viewModel = viewModel ) @@ -232,30 +226,75 @@ private fun CategorySelector( } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PermissionCategories( share: Share, - sharingCapabilities: SharingCapabilities, - expandedCategories: Set, - onToggleCategory: (String) -> Unit, viewModel: ShareViewModel ) { - // We have 3 options in dropdown null, edit, view - // If user selects null ("Can...") just show all permissions without hiding behind collabsection section - // If user selects edit or view dont show any toggle -> immediately update permission + var selectedPreset by remember(share.id) { mutableStateOf(share.permissionPreset) } + + PermissionPresetDropdown( + selectedOption = PermissionPresetOption.from(selectedPreset), + onOptionSelected = { option -> + selectedPreset = option.preset + option.preset?.let { viewModel.updatePermissionPreset(share.id, it) } + } + ) - share.permissions - .forEach { permission -> - key(permission.clazz) { - ShareSwitch( - label = permission.displayName, - checked = permission.enabled, - onCheckedChange = { isChecked -> - viewModel.updatePermission(share.id, permission.clazz, isChecked) + if (selectedPreset != null) return + + share.permissions.forEach { permission -> + key(permission.clazz) { + ShareSwitch( + label = permission.displayName, + checked = permission.enabled, + onCheckedChange = { isChecked -> + viewModel.updatePermission(share.id, permission.clazz, isChecked) + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PermissionPresetDropdown( + selectedOption: PermissionPresetOption, + onOptionSelected: (PermissionPresetOption) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = stringResource(selectedOption.labelRes), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.share_view_permission_preset_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, true) + .fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + PermissionPresetOption.entries.forEach { option -> + DropdownMenuItem( + text = { Text(stringResource(option.labelRes)) }, + onClick = { + expanded = false + onOptionSelected(option) } ) } } + } } @Composable diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt index 8565a604..c39733b3 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt @@ -7,6 +7,7 @@ package com.nextcloud.android.common.ui.share.model.api.request +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset import com.nextcloud.android.common.ui.share.model.api.state.ShareState import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -58,6 +59,11 @@ data class UpdateSharePermissionRequest( val enabled: Boolean ) +@Serializable +data class UpdateSharePermissionPresetRequest( + val permissionPreset: PermissionPreset +) + @Serializable data class UpdateShareRecipientSecretRequest( @SerialName("class") diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOption.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOption.kt new file mode 100644 index 00000000..886a0f75 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOption.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.ui + +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset + +enum class PermissionPresetOption( + val preset: PermissionPreset?, + val labelRes: Int +) { + CUSTOM(null, R.string.share_view_permission_preset_custom), + VIEW(PermissionPreset.VIEW, R.string.share_view_permission_preset_view), + EDIT(PermissionPreset.EDIT, R.string.share_view_permission_preset_edit); + + companion object { + fun from(preset: PermissionPreset?): PermissionPresetOption = + entries.firstOrNull { it.preset == preset } ?: CUSTOM + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt index f2f464b8..f125d92c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -19,6 +19,7 @@ import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionPresetRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipientSecretRequest @@ -450,4 +451,19 @@ class MockShareRepository : ShareRepository { if (index >= 0) mockShares[index] = updated return NetworkResult.Success(updated) } + + override suspend fun updateSharePermissionPreset( + id: String, + request: UpdateSharePermissionPresetRequest + ): NetworkResult { + val index = mockShares.indexOfFirst { it.id == id } + val current = + if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList()) + val updated = current.copy( + permissionPreset = request.permissionPreset, + lastUpdated = System.currentTimeMillis() + ) + if (index >= 0) mockShares[index] = updated + return NetworkResult.Success(updated) + } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index 6498564e..f0bcb16e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -17,6 +17,7 @@ import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionPresetRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipientSecretRequest @@ -188,6 +189,18 @@ class ShareRemoteRepository( json.decodeFromString>(body).ocs.data } + override suspend fun updateSharePermissionPreset( + id: String, + request: UpdateSharePermissionPresetRequest + ): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/permission/preset", + method = HttpMethod.PUT, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { body -> + json.decodeFromString>(body).ocs.data + } + override suspend fun updateShareRecipientSecret( id: String, request: UpdateShareRecipientSecretRequest diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt index 8ce580f3..9ed354e0 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt @@ -11,6 +11,7 @@ import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionPresetRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipientSecretRequest @@ -79,6 +80,11 @@ interface ShareRepository { request: UpdateSharePermissionRequest ): NetworkResult + suspend fun updateSharePermissionPreset( + id: String, + request: UpdateSharePermissionPresetRequest + ): NetworkResult + suspend fun updateShareRecipientSecret( id: String, request: UpdateShareRecipientSecretRequest diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 540a7009..1d2825ed 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -71,4 +71,9 @@ Failed to create share. Failed to update share. + Permissions + Can… + Can view + Can edit + \ No newline at end of file From f23f0a42a3597d07585c0adb3070a2632469e073 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 15:22:21 +0200 Subject: [PATCH 64/95] remove unused strings Signed-off-by: alperozturk96 --- ui/src/main/res/values/strings.xml | 40 +----------------------------- 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 1d2825ed..bd273d2a 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -12,45 +12,15 @@ Create a new share Share %s - File drop - Can view - Can edit - Custom permissions - - View files - Edit files - Create files - Delete files - Settings Add people - Name, team, email or federated ID - Participants Not shared No result - Note to recipients - - Anyone with the link - - Share with others - Edit file - Expiration date - Hide download and sync options - Label - Optional name for this link - Expiration date - Password protection - Limit downloads - Hide downloads - Video verification - - Show files in grid view Copied Copy link Send - Create public link Edit Send email @@ -59,19 +29,11 @@ This share will be permanently removed. Failed to fetch shares. - - Discard new share? - This share hasn\'t been saved yet. Do you want to discard it? - Keep - Discard - - Share not found, cannot delete. Failed to delete share. - Failed to create share. Failed to update share. - Permissions + Participants Can… Can view Can edit From d617f723bc8269aa06ffd43ff6d8b69cc2e1793c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 15:35:11 +0200 Subject: [PATCH 65/95] wip Signed-off-by: alperozturk96 --- .../component/bottomsheet/AddOrEditShareBottomSheet.kt | 7 +++++-- .../android/common/ui/share/model/ui/ShareCategory.kt | 10 ++++++++-- ui/src/main/res/drawable/ic_anyone.xml | 9 +++++++++ ui/src/main/res/values/strings.xml | 2 ++ 4 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 ui/src/main/res/drawable/ic_anyone.xml diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 08c247f4..6362cd5a 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -217,9 +217,12 @@ private fun CategorySelector( SegmentedButton( selected = selectedCategory == category, onClick = { onCategorySelected(category) }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size) + shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size), + icon = { + Icon(painter = painterResource(category.iconId), contentDescription = "") + } ) { - Text(category.name) + Text(stringResource(category.titleId)) } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt index 491b1b57..e58af38d 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt @@ -7,6 +7,12 @@ package com.nextcloud.android.common.ui.share.model.ui -enum class ShareCategory { - Invited, Anyone +import com.nextcloud.android.common.ui.R + +enum class ShareCategory(val titleId: Int, val iconId: Int) { + Invited( + R.string.share_view_invited_category_title, + R.drawable.ic_person_add + ), + Anyone(R.string.share_view_anyone_category_title, R.drawable.ic_anyone) } diff --git a/ui/src/main/res/drawable/ic_anyone.xml b/ui/src/main/res/drawable/ic_anyone.xml new file mode 100644 index 00000000..4e16a67b --- /dev/null +++ b/ui/src/main/res/drawable/ic_anyone.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index bd273d2a..13c62abe 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -38,4 +38,6 @@ Can view Can edit + Invited people + Anyone \ No newline at end of file From 5ba784071c1e706d7820252088a1b40c9419d65d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 15:53:07 +0200 Subject: [PATCH 66/95] wip Signed-off-by: alperozturk96 --- .../component/CollapsibleShareSection.kt | 2 +- .../share/component/RecipientSearchField.kt | 3 +- .../common/ui/share/component/ShareSwitch.kt | 2 +- .../bottomsheet/AddOrEditShareBottomSheet.kt | 173 +++++++++++------- 4 files changed, 112 insertions(+), 68 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt index affe1da5..91e640c4 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt @@ -32,7 +32,7 @@ fun CollapsibleShareSection( onToggle: () -> Unit, content: @Composable () -> Unit ) { - Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Row( modifier = Modifier .fillMaxWidth() diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt index ade91d16..e7d8a84c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons @@ -59,7 +60,7 @@ fun RecipientSearchField( val chipScrollState = rememberScrollState() Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (share.recipients.isNotEmpty()) { diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt index 8af52f43..ebe0744d 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt @@ -31,7 +31,7 @@ fun ShareSwitch( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) - .padding(vertical = 4.dp), + .padding(vertical = 4.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 6362cd5a..e0e842a1 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -11,12 +11,11 @@ package com.nextcloud.android.common.ui.share.component.bottomsheet import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material3.Button @@ -47,6 +46,7 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel @@ -54,10 +54,18 @@ import com.nextcloud.android.common.ui.share.component.CollapsibleShareSection import com.nextcloud.android.common.ui.share.component.RecipientSearchField import com.nextcloud.android.common.ui.share.component.ShareSwitch import com.nextcloud.android.common.ui.share.component.property.SharePropertyView +import com.nextcloud.android.common.ui.share.model.api.permission.Permission +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset +import com.nextcloud.android.common.ui.share.model.api.property.PropertyString +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient +import com.nextcloud.android.common.ui.share.model.api.secret.Secret import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.api.source.Source import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.api.user.User import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption import com.nextcloud.android.common.ui.share.model.ui.ShareCategory +import com.nextcloud.android.common.ui.share.repository.MockShareRepository import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -68,40 +76,50 @@ fun AddOrEditShareBottomSheet( onDismissDraft: (Share) -> Unit = {} ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scrollState = rememberScrollState() val categories = remember { ShareCategory.entries.toList() } var selectedCategory by remember { mutableStateOf(categories.first()) } var showAdvancedSettings by remember { mutableStateOf(false) } + val context = LocalContext.current - ModalBottomSheet( - onDismissRequest = { - if (share.shareState == ShareState.DRAFT) { - onDismissDraft(share) - } else { - viewModel.setActiveShare(null) - } - }, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, + Column( + modifier = Modifier + .fillMaxSize() ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 32.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) + ModalBottomSheet( + onDismissRequest = { + if (share.shareState == ShareState.DRAFT) { + onDismissDraft(share) + } else { + viewModel.setActiveShare(null) + } + }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, ) { - ShareTitle(share) + Text( + text = share.title(context), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(16.dp) + ) - CategorySelector( - categories = categories, - selectedCategory = selectedCategory, - onCategorySelected = { category -> - selectedCategory = category - viewModel.addAnyoneRecipient(category, share) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + categories.forEachIndexed { index, category -> + SegmentedButton( + selected = selectedCategory == category, + onClick = { + selectedCategory = category + viewModel.addAnyoneRecipient(category, share) + }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size), + icon = { + Icon(painter = painterResource(category.iconId), contentDescription = "") + } + ) { + Text(stringResource(category.titleId)) + } } - ) + } if (selectedCategory == ShareCategory.Invited) { RecipientSearchField(share, viewModel) @@ -122,7 +140,7 @@ fun AddOrEditShareBottomSheet( } if (share.readyToSend()) { - Buttons(share, selectedCategory, onSend = { + ActionButtons(share, selectedCategory, onSend = { viewModel.updateState(share.id, ShareState.ACTIVE) }) } @@ -131,7 +149,7 @@ fun AddOrEditShareBottomSheet( } @Composable -private fun Buttons( +private fun ActionButtons( share: Share, category: ShareCategory, onSend: () -> Unit = {}, @@ -172,7 +190,6 @@ private fun Buttons( } } - Button( onClick = onSend, modifier = Modifier.weight(1f), @@ -195,40 +212,6 @@ private fun Buttons( } } -@Composable -private fun ShareTitle(share: Share) { - val context = LocalContext.current - Text( - text = share.title(context), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp) - ) -} - -@Composable -private fun CategorySelector( - categories: List, - selectedCategory: ShareCategory, - onCategorySelected: (ShareCategory) -> Unit -) { - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - categories.forEachIndexed { index, category -> - SegmentedButton( - selected = selectedCategory == category, - onClick = { onCategorySelected(category) }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size), - icon = { - Icon(painter = painterResource(category.iconId), contentDescription = "") - } - ) { - Text(stringResource(category.titleId)) - } - } - } -} - - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun PermissionCategories( @@ -269,6 +252,7 @@ private fun PermissionPresetDropdown( var expanded by remember { mutableStateOf(false) } ExposedDropdownMenuBox( + modifier = Modifier.padding(12.dp), expanded = expanded, onExpandedChange = { expanded = it } ) { @@ -323,3 +307,62 @@ private fun AdvancedSettingsSection( } } } + + +@Preview(showBackground = true) +@Composable +private fun AddOrEditShareBottomSheetPreview() { + MaterialTheme { + AddOrEditShareBottomSheet( + share = previewShare, + viewModel = ShareViewModel(MockShareRepository()) + ) + } +} + +private val previewIcon = + com.nextcloud.android.common.ui.share.model.api.icon.Icon(svg = "") + +private val previewUser = User( + userId = "alice", + displayName = "Alice Johnson", + icon = previewIcon +) + +private val previewShare = Share( + id = "preview", + owner = previewUser, + lastUpdated = 0L, + shareState = ShareState.DRAFT, + sources = listOf( + Source(clazz = "file", value = "/Photos/vacation.jpg", displayName = "vacation.jpg", icon = previewIcon) + ), + recipients = listOf( + Recipient( + clazz = "user", + value = "bob@example.com", + displayName = "Bob Smith", + icon = previewIcon, + secret = Secret(updatable = false, value = "", url = "https://example.com/s/abc123") + ) + ), + properties = listOf( + PropertyString(clazz = "note", displayName = "Note", priority = 10, required = false, value = "") + ), + permissions = listOf( + Permission( + clazz = "read", + displayName = "Read", + presets = listOf(PermissionPreset.VIEW, PermissionPreset.EDIT), + enabled = true + ), + Permission( + clazz = "update", + displayName = "Update", + presets = listOf(PermissionPreset.EDIT), + enabled = false + ) + ), + permissionPreset = null +) + From fa6d5e67e976821f041c13b56a9da2f53eef02c3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 16:03:28 +0200 Subject: [PATCH 67/95] wip Signed-off-by: alperozturk96 --- .../share/component/bottomsheet/AddOrEditShareBottomSheet.kt | 4 ++-- .../common/ui/share/repository/ShareRemoteRepository.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index e0e842a1..e9f6a43d 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -125,7 +125,7 @@ fun AddOrEditShareBottomSheet( RecipientSearchField(share, viewModel) } - PermissionCategories( + PermissionsView( share = share, viewModel = viewModel ) @@ -214,7 +214,7 @@ private fun ActionButtons( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun PermissionCategories( +private fun PermissionsView( share: Share, viewModel: ShareViewModel ) { diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index f0bcb16e..7e09a2f5 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -182,7 +182,7 @@ class ShareRemoteRepository( request: UpdateSharePermissionRequest ): NetworkResult = client.executeRequest( - endpoint = "$SHARE_ENDPOINT/$id/enabled", + endpoint = "$SHARE_ENDPOINT/$id/permission", method = HttpMethod.PUT, body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) ) { body -> From a2fd1714386b967234b11dedb40ed49caf7b9da0 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 16:04:46 +0200 Subject: [PATCH 68/95] wip Signed-off-by: alperozturk96 --- .../ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index e9f6a43d..0060e0e0 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -212,6 +212,7 @@ private fun ActionButtons( } } +// FIXME: when toggles are selected recomposition happens @OptIn(ExperimentalMaterial3Api::class) @Composable private fun PermissionsView( From 13b5d9b79fcdf67c72124775e2f683def64ca016 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 08:11:50 +0200 Subject: [PATCH 69/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 1 - .../bottomsheet/AddOrEditShareBottomSheet.kt | 133 +++++++++--------- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index ec6992cb..a806bbd7 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -299,7 +299,6 @@ class ShareViewModel( val result = repository.updateSharePermission(id, UpdateSharePermissionRequest(clazz, enabled)) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch - _activeShare.update { updated } replaceInList(updated) } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 0060e0e0..49da357a 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -148,71 +148,6 @@ fun AddOrEditShareBottomSheet( } } -@Composable -private fun ActionButtons( - share: Share, - category: ShareCategory, - onSend: () -> Unit = {}, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - val clipEntry = share.getClipEntry() - if (category == ShareCategory.Anyone && clipEntry != null) { - val localClipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - - Button( - onClick = { - scope.launch { - localClipboard.setClipEntry(clipEntry) - } - }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon( - painter = painterResource(R.drawable.ic_link), - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource(R.string.share_view_copy_action), - modifier = Modifier.padding(start = 8.dp), - ) - } - } - - Button( - onClick = onSend, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource(R.string.share_view_send_action), - modifier = Modifier.padding(start = 8.dp), - ) - } - } -} - -// FIXME: when toggles are selected recomposition happens @OptIn(ExperimentalMaterial3Api::class) @Composable private fun PermissionsView( @@ -233,10 +168,12 @@ private fun PermissionsView( share.permissions.forEach { permission -> key(permission.clazz) { + var checked by remember { mutableStateOf(permission.enabled) } ShareSwitch( label = permission.displayName, - checked = permission.enabled, + checked = checked, onCheckedChange = { isChecked -> + checked = isChecked viewModel.updatePermission(share.id, permission.clazz, isChecked) } ) @@ -310,6 +247,70 @@ private fun AdvancedSettingsSection( } +@Composable +private fun ActionButtons( + share: Share, + category: ShareCategory, + onSend: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val clipEntry = share.getClipEntry() + if (category == ShareCategory.Anyone && clipEntry != null) { + val localClipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + Button( + onClick = { + scope.launch { + localClipboard.setClipEntry(clipEntry) + } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_link), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.share_view_copy_action), + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + Button( + onClick = onSend, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.share_view_send_action), + modifier = Modifier.padding(start = 8.dp), + ) + } + } +} + @Preview(showBackground = true) @Composable private fun AddOrEditShareBottomSheetPreview() { From 3f7e6bfdc54c62258c533865be59e2621aac986b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 08:21:17 +0200 Subject: [PATCH 70/95] wip Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 82 ++++++++++--------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 49da357a..cfa8c934 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -15,7 +15,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material3.Button @@ -96,53 +98,59 @@ fun AddOrEditShareBottomSheet( sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, ) { - Text( - text = share.title(context), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(16.dp) - ) + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Text( + text = share.title(context), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(16.dp) + ) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(8.dp)) { - categories.forEachIndexed { index, category -> - SegmentedButton( - selected = selectedCategory == category, - onClick = { - selectedCategory = category - viewModel.addAnyoneRecipient(category, share) - }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size), - icon = { - Icon(painter = painterResource(category.iconId), contentDescription = "") + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + categories.forEachIndexed { index, category -> + SegmentedButton( + selected = selectedCategory == category, + onClick = { + selectedCategory = category + viewModel.addAnyoneRecipient(category, share) + }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size), + icon = { + Icon(painter = painterResource(category.iconId), contentDescription = "") + } + ) { + Text(stringResource(category.titleId)) } - ) { - Text(stringResource(category.titleId)) } } - } - - if (selectedCategory == ShareCategory.Invited) { - RecipientSearchField(share, viewModel) - } - PermissionsView( - share = share, - viewModel = viewModel - ) + if (selectedCategory == ShareCategory.Invited) { + RecipientSearchField(share, viewModel) + } - if (share.properties.isNotEmpty()) { - AdvancedSettingsSection( + PermissionsView( share = share, - isExpanded = showAdvancedSettings, - onToggle = { showAdvancedSettings = !showAdvancedSettings }, viewModel = viewModel ) - } - if (share.readyToSend()) { - ActionButtons(share, selectedCategory, onSend = { - viewModel.updateState(share.id, ShareState.ACTIVE) - }) + if (share.properties.isNotEmpty()) { + AdvancedSettingsSection( + share = share, + isExpanded = showAdvancedSettings, + onToggle = { showAdvancedSettings = !showAdvancedSettings }, + viewModel = viewModel + ) + } + + if (share.readyToSend()) { + ActionButtons(share, selectedCategory, onSend = { + viewModel.updateState(share.id, ShareState.ACTIVE) + }) + } } } } From c45257dfe20246fd2c871a3181151c78ba518fb8 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 08:27:45 +0200 Subject: [PATCH 71/95] wip Signed-off-by: alperozturk96 --- .../ui/network/http/NextcloudHttpClient.kt | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt index 5294b8fd..2475bb45 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt @@ -67,15 +67,22 @@ class NextcloudHttpClient private constructor( try { val response = okHttpClient.newCall(request).execute() val responseBody = response.body.string() - if (response.isSuccessful) { - val envelope = OCSSerializer.json.decodeFromString>(responseBody) - if (envelope.ocs.meta.status != OCS_OK) { - NetworkResult.ServerError(OcsResponse(Ocs(envelope.ocs.meta, envelope.ocs.meta.message))) - } else { - NetworkResult.Success(parse(responseBody)) - } + + if (!response.isSuccessful) { + return@withContext NetworkResult.ServerError( + OCSSerializer.json.decodeFromString>(responseBody) + ) + } + + if (responseBody.isBlank()) { + return@withContext NetworkResult.Success(parse(responseBody)) + } + + val envelope = OCSSerializer.json.decodeFromString>(responseBody) + if (envelope.ocs.meta.status != OCS_OK) { + NetworkResult.ServerError(OcsResponse(Ocs(envelope.ocs.meta, envelope.ocs.meta.message))) } else { - NetworkResult.ServerError(OCSSerializer.json.decodeFromString>(responseBody)) + NetworkResult.Success(parse(responseBody)) } } catch (e: Exception) { NetworkResult.fromException(e) From 9bcbdd24499d1418361d670c7fea8dc2c2a567c5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 08:34:37 +0200 Subject: [PATCH 72/95] wip Signed-off-by: alperozturk96 --- ...SearchField.kt => SelectRecipientField.kt} | 164 +++++++----------- .../bottomsheet/AddOrEditShareBottomSheet.kt | 4 +- ui/src/main/res/values/strings.xml | 4 +- 3 files changed, 65 insertions(+), 107 deletions(-) rename ui/src/main/java/com/nextcloud/android/common/ui/share/component/{RecipientSearchField.kt => SelectRecipientField.kt} (55%) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt similarity index 55% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt index e7d8a84c..82bf6618 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt @@ -7,15 +7,10 @@ package com.nextcloud.android.common.ui.share.component -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.DropdownMenuItem @@ -24,7 +19,6 @@ import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -34,7 +28,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -48,134 +41,97 @@ import com.nextcloud.android.common.ui.share.model.api.icon.Icon import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.share.Share -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun RecipientSearchField( +fun SelectRecipientField( share: Share, viewModel: ShareViewModel ) { - var query by remember { mutableStateOf("") } - var expanded by remember { mutableStateOf(false) } - val results by viewModel.recipientSearchResults.collectAsState() - val chipScrollState = rememberScrollState() + val selectedRecipient = share.recipients.firstOrNull { it.clazz != Recipient.TOKEN_RECIPIENT_CLASS } - Column( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (share.recipients.isNotEmpty()) { - RecipientChipRow( - recipients = share.recipients, - chipScrollState = chipScrollState, - onRemove = { recipient -> - viewModel.removeRecipient( - id = share.id, - clazz = recipient.clazz, - value = recipient.value, - instance = recipient.instance - ) - } - ) - } + if (selectedRecipient == null) { + RecipientSearch(share, viewModel) + return + } - RecipientSearchDropdown( - enabled = share.recipients.isEmpty(), - query = query, - expanded = expanded, - onQueryChange = { - query = it - expanded = true - viewModel.onSearchQueryChanged(it) - }, - onExpandedChange = { expanded = it }, - onDismiss = { expanded = false } - ) { - RecipientDropdownContent( - results = results, - onSelect = { recipient -> - viewModel.addRecipient(share.id, recipient.clazz, recipient.value) - query = "" - expanded = false - } + SelectedRecipient( + recipient = selectedRecipient, + onClear = { + viewModel.removeRecipient( + id = share.id, + clazz = selectedRecipient.clazz, + value = selectedRecipient.value, + instance = selectedRecipient.instance ) } - } + ) } @Composable -private fun RecipientChipRow( - recipients: List, - chipScrollState: androidx.compose.foundation.ScrollState, - onRemove: (Recipient) -> Unit -) { - Row( +private fun SelectedRecipient(recipient: Recipient, onClear: () -> Unit) { + OutlinedTextField( + value = recipient.displayName, + onValueChange = {}, + readOnly = true, + singleLine = true, modifier = Modifier .fillMaxWidth() - .horizontalScroll(chipScrollState), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - recipients.forEach { recipient -> - InputChip( - selected = true, - onClick = { }, - label = { Text(recipient.displayName) }, - leadingIcon = { - recipient.icon?.let { - RecipientIcon( - icon = it, - modifier = Modifier.size(18.dp) - ) - } - }, - trailingIcon = { - IconButton( - onClick = { onRemove(recipient) }, - modifier = Modifier.size(16.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "remove recipient" - ) - } - } - ) + .padding(12.dp), + label = { Text(stringResource(R.string.share_view_select_recipient_label)) }, + leadingIcon = { + recipient.icon?.let { RecipientIcon(icon = it, modifier = Modifier.size(24.dp)) } + }, + trailingIcon = { + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.share_view_select_recipient_clear) + ) + } } - } + ) } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun RecipientSearchDropdown( - enabled: Boolean, - query: String, - expanded: Boolean, - onQueryChange: (String) -> Unit, - onExpandedChange: (Boolean) -> Unit, - onDismiss: () -> Unit, - content: @Composable () -> Unit -) { +private fun RecipientSearch(share: Share, viewModel: ShareViewModel) { + var query by remember { mutableStateOf("") } + var expanded by remember { mutableStateOf(false) } + val results by viewModel.recipientSearchResults.collectAsState() + ExposedDropdownMenuBox( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), expanded = expanded && query.isNotBlank(), - onExpandedChange = onExpandedChange + onExpandedChange = { expanded = it } ) { OutlinedTextField( value = query, - onValueChange = onQueryChange, - label = { Text(stringResource(R.string.share_view_invited_category_label)) }, - enabled = enabled, + onValueChange = { + query = it + expanded = true + viewModel.onSearchQueryChanged(it) + }, + label = { Text(stringResource(R.string.share_view_select_recipient_label)) }, + singleLine = true, modifier = Modifier .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true) - .fillMaxWidth(), - singleLine = true + .fillMaxWidth() ) if (query.isNotBlank()) { ExposedDropdownMenu( expanded = expanded, - onDismissRequest = onDismiss + onDismissRequest = { expanded = false } ) { - content() + RecipientDropdownContent( + results = results, + onSelect = { recipient -> + viewModel.addRecipient(share.id, recipient.clazz, recipient.value) + query = "" + expanded = false + } + ) } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index cfa8c934..525c6a1a 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -53,7 +53,7 @@ import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel import com.nextcloud.android.common.ui.share.component.CollapsibleShareSection -import com.nextcloud.android.common.ui.share.component.RecipientSearchField +import com.nextcloud.android.common.ui.share.component.SelectRecipientField import com.nextcloud.android.common.ui.share.component.ShareSwitch import com.nextcloud.android.common.ui.share.component.property.SharePropertyView import com.nextcloud.android.common.ui.share.model.api.permission.Permission @@ -129,7 +129,7 @@ fun AddOrEditShareBottomSheet( } if (selectedCategory == ShareCategory.Invited) { - RecipientSearchField(share, viewModel) + SelectRecipientField(share, viewModel) } PermissionsView( diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 13c62abe..7b545735 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -13,7 +13,9 @@ Share %s Settings - Add people + + Select recipient + Clear recipient Not shared No result From 03b92a3adbb8733175debcc7687d89566f2ed791 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 08:36:28 +0200 Subject: [PATCH 73/95] wip Signed-off-by: alperozturk96 --- ui/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 7b545735..607d17f5 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -14,7 +14,7 @@ Settings - Select recipient + Recipient Clear recipient Not shared From bdd9682dba782c36f3411c8f9c3083d534927733 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 09:06:38 +0200 Subject: [PATCH 74/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 87 ++++++------------- .../bottomsheet/AddOrEditShareBottomSheet.kt | 25 +++++- .../property/SharePropertyEnumField.kt | 75 ++++++++++++++++ .../component/property/SharePropertyView.kt | 42 ++++----- .../property/datepicker/ShareDatePicker.kt | 9 +- .../datepicker/util/ShareDateFormatter.kt | 16 +++- ui/src/main/res/values/strings.xml | 1 + 7 files changed, 164 insertions(+), 91 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyEnumField.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index a806bbd7..62d57419 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -30,9 +30,6 @@ import com.nextcloud.android.common.ui.share.repository.ShareRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -50,8 +47,8 @@ class ShareViewModel( ) : ViewModel() { companion object { - private const val SEARCH_DEBOUNCE_DELAY_MS = 300L - private const val PROPERTY_DEBOUNCE_DELAY_MS = 1000L + private const val SEARCH_DEBOUNCE_DELAY = 300L + private const val PROPERTY_DEBOUNCE_DELAY = 1000L } private val _state = MutableStateFlow(ShareScreenState.Loading) @@ -68,12 +65,10 @@ class ShareViewModel( private val _errorMessageId = MutableStateFlow(null) val errorMessageId: StateFlow = _errorMessageId - private data class PendingPropertyUpdate(val job: Job, val shareId: String, val value: String) + private val _propertyErrors = MutableStateFlow>(emptyMap()) + val propertyErrors: StateFlow> = _propertyErrors - private val pendingPropertyJobs = mutableMapOf() - - private val _propertyErrors = MutableStateFlow>(emptyMap()) - val propertyErrors: StateFlow> = _propertyErrors + private val propertyUpdateJobs = mutableMapOf() private val currentShares: List get() = (_state.value as? ShareScreenState.Loaded)?.shares ?: emptyList() @@ -88,7 +83,7 @@ class ShareViewModel( private fun initSearchQuery() { viewModelScope.launch { _searchQuery - .debounce(SEARCH_DEBOUNCE_DELAY_MS.milliseconds) + .debounce(SEARCH_DEBOUNCE_DELAY.milliseconds) .distinctUntilChanged() .filter { it.isNotBlank() } .collect { query -> executeSearch(query) } @@ -160,11 +155,8 @@ class ShareViewModel( // region state fun updateState(id: String, shareState: ShareState) { - viewModelScope.launch { - flushPendingProperties(id) - val result = withContext(Dispatchers.IO) { - repository.updateShareState(id, UpdateShareStateRequest(shareState)) - } + viewModelScope.launch(Dispatchers.IO) { + val result = repository.updateShareState(id, UpdateShareStateRequest(shareState)) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch if (shareState == ShareState.ACTIVE) { @@ -243,51 +235,24 @@ class ShareViewModel( // endregion // region properties - fun updateProperty(id: String, clazz: String, value: String?) { - viewModelScope.launch(Dispatchers.IO) { - val result = repository.updateShareProperty(id, UpdateSharePropertyRequest(clazz, value)) - val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } - ?: return@launch - _activeShare.update { updated } - replaceInList(updated) - } - } - - fun updatePropertyDebounced(shareId: String, clazz: String, value: String) { - pendingPropertyJobs[clazz]?.job?.cancel() - val job = viewModelScope.launch { - delay(PROPERTY_DEBOUNCE_DELAY_MS.milliseconds) - pendingPropertyJobs.remove(clazz) - withContext(Dispatchers.IO) { executePropertyUpdate(shareId, clazz, value) } - } - pendingPropertyJobs[clazz] = PendingPropertyUpdate(job, shareId, value) - } - - private suspend fun flushPendingProperties(shareId: String) { - val snapshot = pendingPropertyJobs.toMap() - snapshot.values.forEach { it.job.cancel() } - pendingPropertyJobs.clear() - if (snapshot.isEmpty()) return - withContext(Dispatchers.IO) { - coroutineScope { - snapshot.entries.map { (clazz, pending) -> - async { executePropertyUpdate(shareId, clazz, pending.value) } - }.awaitAll() - } - } - } - - private suspend fun executePropertyUpdate(shareId: String, clazz: String, value: String) { - when (val result = repository.updateShareProperty(shareId, UpdateSharePropertyRequest(clazz, value))) { - is NetworkResult.Success -> { - _propertyErrors.update { it - clazz } - _activeShare.update { result.data } - replaceInList(result.data) - } - - is NetworkResult.ServerError, - is NetworkResult.NetworkException -> { - _propertyErrors.update { it + (clazz to R.string.share_view_update_error_message) } + fun updateProperty(shareId: String, clazz: String, value: String?) { + propertyUpdateJobs[clazz]?.cancel() + propertyUpdateJobs[clazz] = viewModelScope.launch(Dispatchers.IO) { + delay(PROPERTY_DEBOUNCE_DELAY.milliseconds) + when (val result = repository.updateShareProperty(shareId, UpdateSharePropertyRequest(clazz, value))) { + is NetworkResult.Success -> { + _propertyErrors.update { it - clazz } + _activeShare.update { result.data } + replaceInList(result.data) + } + + is NetworkResult.ServerError -> { + _propertyErrors.update { it + (clazz to result.response.ocs.meta.message) } + } + + is NetworkResult.NetworkException -> { + _propertyErrors.update { it + (clazz to null) } + } } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 525c6a1a..a4bb3072 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -82,6 +83,8 @@ fun AddOrEditShareBottomSheet( var selectedCategory by remember { mutableStateOf(categories.first()) } var showAdvancedSettings by remember { mutableStateOf(false) } val context = LocalContext.current + val propertyErrors by viewModel.propertyErrors.collectAsState() + val hasPropertyErrors = propertyErrors.values.any { it != null } Column( modifier = Modifier @@ -146,10 +149,24 @@ fun AddOrEditShareBottomSheet( ) } + if (hasPropertyErrors) { + Text( + text = stringResource(R.string.share_view_property_error_warning), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + if (share.readyToSend()) { - ActionButtons(share, selectedCategory, onSend = { - viewModel.updateState(share.id, ShareState.ACTIVE) - }) + ActionButtons( + share = share, + category = selectedCategory, + sendEnabled = !hasPropertyErrors, + onSend = { viewModel.updateState(share.id, ShareState.ACTIVE) } + ) } } } @@ -259,6 +276,7 @@ private fun AdvancedSettingsSection( private fun ActionButtons( share: Share, category: ShareCategory, + sendEnabled: Boolean, onSend: () -> Unit, ) { Row( @@ -299,6 +317,7 @@ private fun ActionButtons( Button( onClick = onSend, + enabled = sendEnabled, modifier = Modifier.weight(1f), shape = RoundedCornerShape(10.dp), colors = ButtonDefaults.buttonColors( diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyEnumField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyEnumField.kt new file mode 100644 index 00000000..301bd373 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyEnumField.kt @@ -0,0 +1,75 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component.property + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.share.model.api.property.PropertyEnum + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SharePropertyEnumField( + property: PropertyEnum, + errorMessage: String?, + onValueSelected: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + var selectedValue by remember(property.clazz) { mutableStateOf(property.value ?: "") } + + ExposedDropdownMenuBox( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = selectedValue, + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { Text(property.displayName) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + isError = errorMessage != null, + supportingText = errorMessage?.let { { Text(it) } }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, true) + .fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + property.validValues.forEach { value -> + DropdownMenuItem( + text = { Text(value) }, + onClick = { + selectedValue = value + expanded = false + onValueSelected(value) + } + ) + } + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt index ca482a15..a5318ddb 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt @@ -20,7 +20,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp @@ -38,7 +37,12 @@ import com.nextcloud.android.common.ui.share.model.api.property.PropertyString @Composable fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewModel) { val propertyErrors by viewModel.propertyErrors.collectAsState() - val hasError = propertyErrors[property.clazz] != null + val fallbackError = stringResource(R.string.share_view_update_error_message) + val errorMessage = if (propertyErrors.containsKey(property.clazz)) { + propertyErrors[property.clazz] ?: fallbackError + } else { + null + } when (property) { is PropertyBoolean -> { @@ -50,12 +54,12 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM checked = checkedValue, onCheckedChange = { isChecked -> checkedValue = isChecked - viewModel.updatePropertyDebounced(shareId, property.clazz, isChecked.toString()) + viewModel.updateProperty(shareId, property.clazz, isChecked.toString()) } ) - if (hasError) { + if (errorMessage != null) { Text( - text = stringResource(R.string.share_view_update_error_message), + text = errorMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp) @@ -71,15 +75,11 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM value = textValue, onValueChange = { textValue = it - viewModel.updatePropertyDebounced(shareId, property.clazz, it) + viewModel.updateProperty(shareId, property.clazz, it) }, label = { Text(property.displayName) }, - isError = hasError, - supportingText = if (hasError) { - { Text(stringResource(R.string.share_view_update_error_message)) } - } else { - null - }, + isError = errorMessage != null, + supportingText = errorMessage?.let { { Text(it) } }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), singleLine = true ) @@ -92,17 +92,13 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM value = passwordValue, onValueChange = { passwordValue = it - viewModel.updatePropertyDebounced(shareId, property.clazz, it) + viewModel.updateProperty(shareId, property.clazz, it) }, label = { Text(property.displayName) }, placeholder = property.hint?.let { { Text(it) } }, visualTransformation = PasswordVisualTransformation(), - isError = hasError, - supportingText = if (hasError) { - { Text(stringResource(R.string.share_view_update_error_message)) } - } else { - null - }, + isError = errorMessage != null, + supportingText = errorMessage?.let { { Text(it) } }, modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), @@ -111,13 +107,17 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM } is PropertyDate -> { - ShareDatePicker(property, onDateSelected = { dateValue -> + ShareDatePicker(property, errorMessage = errorMessage, onDateSelected = { dateValue -> viewModel.updateProperty(shareId, property.clazz, dateValue) }) } is PropertyEnum -> { - Text(text = "Enum Property: ${property.displayName} (Under Construction)", color = Color.Gray) + SharePropertyEnumField( + property = property, + errorMessage = errorMessage, + onValueSelected = { value -> viewModel.updateProperty(shareId, property.clazz, value) } + ) } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt index 186b9238..0e8c76c9 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt @@ -32,10 +32,10 @@ import com.nextcloud.android.common.ui.share.component.property.datepicker.util. import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate @Composable -fun ShareDatePicker(property: PropertyDate, onDateSelected: (String) -> Unit) { - val formatter = ShareDateFormatter() +fun ShareDatePicker(property: PropertyDate, errorMessage: String?, onDateSelected: (String) -> Unit) { + val formatter = remember { ShareDateFormatter() } var showDatePicker by remember { mutableStateOf(false) } - var dateValue by remember { mutableStateOf(property.value ?: "") } + var dateValue by remember(property.clazz) { mutableStateOf(formatter.formatIsoForDisplay(property.value)) } Box(modifier = Modifier.fillMaxWidth()) { OutlinedTextField( @@ -43,6 +43,8 @@ fun ShareDatePicker(property: PropertyDate, onDateSelected: (String) -> Unit) { onValueChange = {}, readOnly = true, enabled = true, + isError = errorMessage != null, + supportingText = errorMessage?.let { { Text(it) } }, modifier = Modifier.fillMaxWidth(), label = { Text(formatter.getDisplayName(property)) }, trailingIcon = { @@ -117,6 +119,7 @@ private fun ShareDatePickerPreview() { minDate = "2026-01-01", maxDate = "2027-12-31" ), + errorMessage = null, onDateSelected = {} ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt index b63e69f9..8e766bfb 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt @@ -11,6 +11,8 @@ import androidx.compose.material3.DatePickerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.getSelectedDate import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate +import java.time.LocalDate +import java.time.OffsetDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -18,17 +20,25 @@ import java.util.Locale class ShareDateFormatter { + private val displayDateFormatter: DateTimeFormatter = + DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(Locale.getDefault()) + fun getDisplayName(property: PropertyDate): String = property.displayName @OptIn(ExperimentalMaterial3Api::class) fun formatDisplayDate(datePickerState: DatePickerState): String? = - datePickerState.getSelectedDate()?.format( - DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(Locale.getDefault()) - ) + datePickerState.getSelectedDate()?.format(displayDateFormatter) @OptIn(ExperimentalMaterial3Api::class) fun formatIso8601Date(datePickerState: DatePickerState): String? = datePickerState.getSelectedDate() ?.atStartOfDay(ZoneId.systemDefault()) ?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + + fun formatIsoForDisplay(isoDate: String?): String { + if (isoDate.isNullOrBlank()) return "" + return runCatching { OffsetDateTime.parse(isoDate).toLocalDate().format(displayDateFormatter) } + .recoverCatching { LocalDate.parse(isoDate).format(displayDateFormatter) } + .getOrDefault(isoDate) + } } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 607d17f5..dbeb63e9 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -34,6 +34,7 @@ Failed to delete share. Failed to create share. Failed to update share. + Some settings couldn\'t be saved. Fix the highlighted fields before sharing. Participants Can… From bf2b2410040b448063ed6bda035a7628d22b3c7c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 09:25:02 +0200 Subject: [PATCH 75/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 21 ++++++++++ .../ui/share/component/RecipientTokenField.kt | 41 +++++++++++++++++++ .../bottomsheet/AddOrEditShareBottomSheet.kt | 16 ++++++-- .../common/ui/share/model/api/share/Share.kt | 11 ++--- ui/src/main/res/values/strings.xml | 1 + 5 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientTokenField.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 62d57419..ceabfb13 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -20,6 +20,7 @@ import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPres import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionPresetRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipientSecretRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState @@ -70,6 +71,8 @@ class ShareViewModel( private val propertyUpdateJobs = mutableMapOf() + private var secretUpdateJob: Job? = null + private val currentShares: List get() = (_state.value as? ShareScreenState.Loaded)?.shares ?: emptyList() @@ -232,6 +235,24 @@ class ShareViewModel( replaceInList(updated) } } + + fun updateRecipientSecret(shareId: String, recipient: Recipient, secret: String) { + secretUpdateJob?.cancel() + secretUpdateJob = viewModelScope.launch(Dispatchers.IO) { + delay(PROPERTY_DEBOUNCE_DELAY.milliseconds) + val request = UpdateShareRecipientSecretRequest( + clazz = recipient.clazz, + value = recipient.value, + instance = recipient.instance, + secret = secret + ) + val result = repository.updateShareRecipientSecret(shareId, request) + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + _activeShare.update { updated } + replaceInList(updated) + } + } // endregion // region properties diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientTokenField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientTokenField.kt new file mode 100644 index 00000000..11a7948d --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientTokenField.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient + +@Composable +fun RecipientTokenField(recipient: Recipient, onTokenChange: (String) -> Unit) { + var token by remember(recipient.value) { mutableStateOf(recipient.secret.value ?: "") } + + OutlinedTextField( + value = token, + onValueChange = { + token = it + onTokenChange(it) + }, + label = { Text(stringResource(R.string.share_view_link_token_label)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index a4bb3072..adbbcc58 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel import com.nextcloud.android.common.ui.share.component.CollapsibleShareSection +import com.nextcloud.android.common.ui.share.component.RecipientTokenField import com.nextcloud.android.common.ui.share.component.SelectRecipientField import com.nextcloud.android.common.ui.share.component.ShareSwitch import com.nextcloud.android.common.ui.share.component.property.SharePropertyView @@ -135,6 +136,17 @@ fun AddOrEditShareBottomSheet( SelectRecipientField(share, viewModel) } + // TODO: is it going to be included for now? + val editableRecipient = share.recipients.firstOrNull { it.secret.updatable } + if (editableRecipient != null) { + RecipientTokenField( + recipient = editableRecipient, + onTokenChange = { token -> + viewModel.updateRecipientSecret(share.id, editableRecipient, token) + } + ) + } + PermissionsView( share = share, viewModel = viewModel @@ -163,7 +175,6 @@ fun AddOrEditShareBottomSheet( if (share.readyToSend()) { ActionButtons( share = share, - category = selectedCategory, sendEnabled = !hasPropertyErrors, onSend = { viewModel.updateState(share.id, ShareState.ACTIVE) } ) @@ -275,7 +286,6 @@ private fun AdvancedSettingsSection( @Composable private fun ActionButtons( share: Share, - category: ShareCategory, sendEnabled: Boolean, onSend: () -> Unit, ) { @@ -286,7 +296,7 @@ private fun ActionButtons( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { val clipEntry = share.getClipEntry() - if (category == ShareCategory.Anyone && clipEntry != null) { + if (clipEntry != null) { val localClipboard = LocalClipboard.current val scope = rememberCoroutineScope() diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 435c20f5..33c22c8b 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -45,15 +45,12 @@ data class Share( @SerialName("permission_preset") val permissionPreset: PermissionPreset? = null ) { + // FIXME: secret.url is null fun getClipEntry(): ClipEntry? { - val label = recipients.first().displayName - val link = recipients.firstOrNull()?.secret?.url + val recipient = recipients.firstOrNull() ?: return null + val link = recipient.secret.url ?: return null - return if (link != null) { - ClipData.newPlainText(label, link).toClipEntry() - } else { - null - } + return ClipData.newPlainText(recipient.displayName, link).toClipEntry() } fun readyToSend(): Boolean = diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index dbeb63e9..f85d009d 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -15,6 +15,7 @@ Settings Recipient + Link token Clear recipient Not shared From 771c5dfc852153f81107f7e9fbba8722289c685c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 09:37:58 +0200 Subject: [PATCH 76/95] wip Signed-off-by: alperozturk96 --- .../android/common/sample/MainViewModel.kt | 2 +- ui/build.gradle | 1 + .../ui/network/http/NextcloudHttpClient.kt | 24 ++--- .../network/http/NextcloudHttpClientLogger.kt | 92 +++++++++++++++++++ 4 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClientLogger.kt diff --git a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt index 4744a557..58e6bdf4 100644 --- a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt +++ b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt @@ -27,7 +27,7 @@ class MainViewModel : ViewModel() { ) { viewModelScope.launch { val credentials = ServerCredentials(baseUrl, username, token) - val client = NextcloudHttpClient.create(credentials, enableLogging = true) + val client = NextcloudHttpClient.create(credentials) val service = UserStatusRepository(client) when (val result = service.fetchPredefinedStatuses()) { diff --git a/ui/build.gradle b/ui/build.gradle index 20ce363b..9c6c77a3 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -24,6 +24,7 @@ android { buildFeatures { compose = true + buildConfig = true } compileOptions { diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt index 2475bb45..29b462e6 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt @@ -7,6 +7,7 @@ package com.nextcloud.android.common.ui.network.http +import com.nextcloud.android.common.ui.BuildConfig import com.nextcloud.android.common.ui.network.auth.AuthInterceptor import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.model.NetworkResult @@ -18,12 +19,12 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonElement import okhttp3.OkHttpClient import okhttp3.RequestBody -import okhttp3.logging.HttpLoggingInterceptor import java.util.concurrent.TimeUnit class NextcloudHttpClient private constructor( val okHttpClient: OkHttpClient, - val credentials: ServerCredentials + val credentials: ServerCredentials, + private val debugLogger: NextcloudHttpClientLogger ) { companion object { private const val CONNECT_TIMEOUT_SECONDS = 90L @@ -32,26 +33,16 @@ class NextcloudHttpClient private constructor( private const val OCS_OK = "ok" fun create( - credentials: ServerCredentials, - enableLogging: Boolean = false + credentials: ServerCredentials ): NextcloudHttpClient { - val loggingInterceptor = HttpLoggingInterceptor().apply { - level = if (enableLogging) { - HttpLoggingInterceptor.Level.BODY - } else { - HttpLoggingInterceptor.Level.NONE - } - } - val okHttpClient = OkHttpClient.Builder() .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) .writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS) .addInterceptor(AuthInterceptor(credentials)) - .addInterceptor(loggingInterceptor) .build() - return NextcloudHttpClient(okHttpClient, credentials) + return NextcloudHttpClient(okHttpClient, credentials, NextcloudHttpClientLogger(BuildConfig.DEBUG)) } } @@ -69,22 +60,27 @@ class NextcloudHttpClient private constructor( val responseBody = response.body.string() if (!response.isSuccessful) { + debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = true) return@withContext NetworkResult.ServerError( OCSSerializer.json.decodeFromString>(responseBody) ) } if (responseBody.isBlank()) { + debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = false) return@withContext NetworkResult.Success(parse(responseBody)) } val envelope = OCSSerializer.json.decodeFromString>(responseBody) if (envelope.ocs.meta.status != OCS_OK) { + debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = true) NetworkResult.ServerError(OcsResponse(Ocs(envelope.ocs.meta, envelope.ocs.meta.message))) } else { + debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = false) NetworkResult.Success(parse(responseBody)) } } catch (e: Exception) { + debugLogger.logException(endpoint, method, e) NetworkResult.fromException(e) } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClientLogger.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClientLogger.kt new file mode 100644 index 00000000..98d66e0c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClientLogger.kt @@ -0,0 +1,92 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network.http + +import android.util.Log +import kotlinx.serialization.json.Json +import okhttp3.RequestBody +import okio.Buffer + +internal class NextcloudHttpClientLogger(private val enabled: Boolean) { + + companion object { + private const val TAG = "NextcloudHttpClient" + + private val prettyJson = Json { + prettyPrint = true + prettyPrintIndent = " " + ignoreUnknownKeys = true + } + } + + fun logRequest(endpoint: String, method: HttpMethod, body: RequestBody?) { + if (!enabled) return + + Log.d( + TAG, + """ + |➡️ REQUEST [$method] $endpoint + |Body: + |${body.readableString().prettyPrintJsonOrRaw()} + """.trimMargin() + ) + } + + fun logResponse( + endpoint: String, + method: HttpMethod, + code: Int, + responseBody: String, + isError: Boolean + ) { + if (!enabled) return + + Log.d( + TAG, + """ + |${statusEmojiFor(code, isError)} RESPONSE [$method] $endpoint (HTTP $code) + |Body: + |${responseBody.ifBlank { "" }.prettyPrintJsonOrRaw()} + """.trimMargin() + ) + } + + fun logException(endpoint: String, method: HttpMethod, e: Exception) { + if (!enabled) return + + Log.e(TAG, "💥 EXCEPTION [$method] $endpoint -> ${e.javaClass.simpleName}: ${e.message}", e) + } + + private fun statusEmojiFor(code: Int, isError: Boolean): String = when { + isError -> "🔴" + code in 200..299 -> "🟢" + code in 300..399 -> "🟡" + else -> "🔴" + } + + private fun RequestBody?.readableString(): String { + if (this == null) return "" + return try { + val buffer = Buffer() + writeTo(buffer) + buffer.readUtf8() + } catch (e: Exception) { + "" + } + } + + private fun String.prettyPrintJsonOrRaw(): String { + if (this.isBlank() || this == "" || this == "") return this + return try { + val element = Json.parseToJsonElement(this) + prettyJson.encodeToString(kotlinx.serialization.json.JsonElement.serializer(), element) + } catch (_: Exception) { + this + } + } +} From 44cbe443d02b2defe3f44f301bc3ad34b2ace57a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 12:24:44 +0200 Subject: [PATCH 77/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 80 ++++++++++++++++--- .../QuickSharePermissionBottomSheet.kt | 76 ++++++++++++++++++ .../share/model/ui/ShareItemOverlayState.kt | 1 + ui/src/main/res/values/strings.xml | 1 + 4 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/QuickSharePermissionBottomSheet.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index ccd48760..aaaac389 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -8,16 +8,23 @@ package com.nextcloud.android.common.ui.share import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ColorScheme @@ -62,9 +69,12 @@ import com.nextcloud.android.common.ui.network.auth.ServerCredentials import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient import com.nextcloud.android.common.ui.share.component.RecipientIcon import com.nextcloud.android.common.ui.share.component.bottomsheet.AddOrEditShareBottomSheet +import com.nextcloud.android.common.ui.share.component.bottomsheet.QuickSharePermissionBottomSheet import com.nextcloud.android.common.ui.share.component.dialog.DeleteShareConfirmationDialog import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption import com.nextcloud.android.common.ui.share.model.ui.ShareItemOverlayState import com.nextcloud.android.common.ui.share.model.ui.ShareItemType import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState @@ -117,12 +127,14 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti CircularProgressIndicator() } } + is ShareScreenState.Empty -> { ContentUnavailableView( iconId = R.drawable.ic_person_add, title = stringResource(R.string.share_view_empty_title), ) } + is ShareScreenState.Loaded -> { LazyColumn( modifier = Modifier @@ -143,6 +155,9 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti share = share, type = type, onSelectShare = { selected -> viewModel.setActiveShare(selected) }, + onChangePreset = { selected, preset -> + viewModel.updatePermissionPreset(selected.id, preset) + }, onDeleteShare = { viewModel.deleteShare(it.id) }, onSendEmail = { } ) @@ -169,20 +184,41 @@ private fun ShareItem( share: Share, type: ShareItemType, onSelectShare: (Share) -> Unit, + onChangePreset: (Share, PermissionPreset) -> Unit, onDeleteShare: (Share) -> Unit, onSendEmail: (Share) -> Unit ) { var overlayState by remember { mutableStateOf(ShareItemOverlayState.None) } val haptics = LocalHapticFeedback.current - if (overlayState == ShareItemOverlayState.DeleteConfirmation) { - DeleteShareConfirmationDialog( - onConfirm = { - overlayState = ShareItemOverlayState.None - onDeleteShare(share) - }, - onDismiss = { overlayState = ShareItemOverlayState.None } - ) + when (overlayState) { + is ShareItemOverlayState.QuickShare -> { + QuickSharePermissionBottomSheet( + selectedOption = PermissionPresetOption.from(share.permissionPreset), + onOptionSelected = { option -> + overlayState = ShareItemOverlayState.None + val preset = option.preset + if (preset != null) { + onChangePreset(share, preset) + } else { + onSelectShare(share) + } + }, + onDismiss = { overlayState = ShareItemOverlayState.None } + ) + } + + is ShareItemOverlayState.DeleteConfirmation -> { + DeleteShareConfirmationDialog( + onConfirm = { + overlayState = ShareItemOverlayState.None + onDeleteShare(share) + }, + onDismiss = { overlayState = ShareItemOverlayState.None } + ) + } + + else -> Unit } ListItem( @@ -213,11 +249,20 @@ private fun ShareItem( share.recipients.first().icon?.let { RecipientIcon(icon = it) } }, supportingContent = { - Text( - text = share.shareState.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row( + modifier = Modifier.clickable { overlayState = ShareItemOverlayState.QuickShare }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(PermissionPresetOption.from(share.permissionPreset).labelRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(4.dp)) + + Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Down arrow") + } + }, trailingContent = { Box { @@ -230,6 +275,9 @@ private fun ShareItem( onDismissRequest = { overlayState = ShareItemOverlayState.None } ) { DropdownMenuItem( + leadingIcon = { + Icon(Icons.Default.Edit, contentDescription = "Edit icon") + }, text = { Text(stringResource(R.string.share_view_list_item_edit)) }, onClick = { overlayState = ShareItemOverlayState.None @@ -237,6 +285,9 @@ private fun ShareItem( } ) DropdownMenuItem( + leadingIcon = { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send icon") + }, text = { Text(stringResource(R.string.share_view_list_item_send_email)) }, onClick = { onSendEmail(share) @@ -245,6 +296,9 @@ private fun ShareItem( ) HorizontalDivider() DropdownMenuItem( + leadingIcon = { + Icon(Icons.Default.Delete, contentDescription = "Delete icon") + }, text = { Text( stringResource(R.string.share_view_list_item_delete), diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/QuickSharePermissionBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/QuickSharePermissionBottomSheet.kt new file mode 100644 index 00000000..0fdbbe75 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/QuickSharePermissionBottomSheet.kt @@ -0,0 +1,76 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.component.bottomsheet + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuickSharePermissionBottomSheet( + selectedOption: PermissionPresetOption, + onOptionSelected: (PermissionPresetOption) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp) + ) { + Text( + text = stringResource(R.string.share_view_quick_permission_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(16.dp) + ) + + PermissionPresetOption.entries.forEach { option -> + ListItem( + modifier = Modifier.clickable { onOptionSelected(option) }, + headlineContent = { Text(stringResource(option.labelRes)) }, + trailingContent = { + if (option == selectedOption) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + } + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemOverlayState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemOverlayState.kt index 0e50bb29..0b47eff0 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemOverlayState.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemOverlayState.kt @@ -10,5 +10,6 @@ package com.nextcloud.android.common.ui.share.model.ui sealed class ShareItemOverlayState { data object None : ShareItemOverlayState() data object ContextMenu : ShareItemOverlayState() + data object QuickShare : ShareItemOverlayState() data object DeleteConfirmation : ShareItemOverlayState() } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index f85d009d..0d432dd0 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -37,6 +37,7 @@ Failed to update share. Some settings couldn\'t be saved. Fix the highlighted fields before sharing. + Set permission Participants Can… Can view From 74a074ad7262c336aa3073ee366ff1fe2a8b1064 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 12:43:33 +0200 Subject: [PATCH 78/95] wip Signed-off-by: alperozturk96 --- .../com/nextcloud/android/common/ui/share/ShareScreen.kt | 4 ++-- .../com/nextcloud/android/common/ui/share/ShareViewModel.kt | 6 ++++-- .../component/bottomsheet/AddOrEditShareBottomSheet.kt | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index aaaac389..05d050c4 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -156,10 +156,10 @@ private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabiliti type = type, onSelectShare = { selected -> viewModel.setActiveShare(selected) }, onChangePreset = { selected, preset -> - viewModel.updatePermissionPreset(selected.id, preset) + viewModel.updatePermissionPreset(selected.id, preset, updateActiveShare = false) }, onDeleteShare = { viewModel.deleteShare(it.id) }, - onSendEmail = { } + onSendEmail = { selected -> viewModel.setActiveShare(selected) } // TODO: is that right? ) } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index ceabfb13..3fbceb82 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -289,12 +289,14 @@ class ShareViewModel( } } - fun updatePermissionPreset(id: String, preset: PermissionPreset) { + fun updatePermissionPreset(id: String, preset: PermissionPreset, updateActiveShare: Boolean) { viewModelScope.launch(Dispatchers.IO) { val result = repository.updateSharePermissionPreset(id, UpdateSharePermissionPresetRequest(preset)) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch - _activeShare.update { updated } + if (updateActiveShare) { + _activeShare.update { updated } + } replaceInList(updated) } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index adbbcc58..2472f823 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -196,7 +196,7 @@ private fun PermissionsView( selectedOption = PermissionPresetOption.from(selectedPreset), onOptionSelected = { option -> selectedPreset = option.preset - option.preset?.let { viewModel.updatePermissionPreset(share.id, it) } + option.preset?.let { viewModel.updatePermissionPreset(share.id, it, true) } } ) From 71f132645128951fc24e7630bf935f9dbd82ad5b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 12:44:12 +0200 Subject: [PATCH 79/95] wip Signed-off-by: alperozturk96 --- .../com/nextcloud/android/common/ui/share/ShareScreen.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index 05d050c4..1d7b1e83 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -84,7 +84,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @Composable -private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabilities, viewModel: ShareViewModel) { +private fun ShareScreen(sourceId: String, viewModel: ShareViewModel) { val errorMessageId by viewModel.errorMessageId.collectAsState() val screenState by viewModel.state.collectAsState() val activeShare by viewModel.activeShare.collectAsState() @@ -316,15 +316,11 @@ private fun ShareItem( ) } -private val json = Json { ignoreUnknownKeys = true } - fun ComposeView.initShareScreen( sourceId: String, - sharingJson: String, credentials: ServerCredentials, colorScheme: ColorScheme ) { - val sharingCapabilities = json.decodeFromString(sharingJson) val nextcloudHttpClient = NextcloudHttpClient.create(credentials) val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)) @@ -338,7 +334,7 @@ fun ComposeView.initShareScreen( MaterialTheme( colorScheme = colorScheme, content = { - ShareScreen(sourceId, sharingCapabilities, viewModel) + ShareScreen(sourceId, viewModel) } ) } From 3e326fd240c58c3732b88396c34806074d532d57 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 12:51:00 +0200 Subject: [PATCH 80/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 21 ++++++++++++++----- .../bottomsheet/AddOrEditShareBottomSheet.kt | 7 ++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index 1d7b1e83..b1909300 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -71,7 +71,6 @@ import com.nextcloud.android.common.ui.share.component.RecipientIcon import com.nextcloud.android.common.ui.share.component.bottomsheet.AddOrEditShareBottomSheet import com.nextcloud.android.common.ui.share.component.bottomsheet.QuickSharePermissionBottomSheet import com.nextcloud.android.common.ui.share.component.dialog.DeleteShareConfirmationDialog -import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption @@ -81,7 +80,6 @@ import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState import com.nextcloud.android.common.ui.share.model.ui.filtered import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json @Composable private fun ShareScreen(sourceId: String, viewModel: ShareViewModel) { @@ -91,6 +89,7 @@ private fun ShareScreen(sourceId: String, viewModel: ShareViewModel) { val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } val resources = LocalResources.current + var editorInitialPreset by remember { mutableStateOf(null) } LaunchedEffect(errorMessageId) { errorMessageId?.let { @@ -154,12 +153,22 @@ private fun ShareScreen(sourceId: String, viewModel: ShareViewModel) { ShareItem( share = share, type = type, - onSelectShare = { selected -> viewModel.setActiveShare(selected) }, + onSelectShare = { selected -> + editorInitialPreset = null + viewModel.setActiveShare(selected) + }, + onCustomizeShare = { selected -> + editorInitialPreset = PermissionPresetOption.CUSTOM + viewModel.setActiveShare(selected) + }, onChangePreset = { selected, preset -> viewModel.updatePermissionPreset(selected.id, preset, updateActiveShare = false) }, onDeleteShare = { viewModel.deleteShare(it.id) }, - onSendEmail = { selected -> viewModel.setActiveShare(selected) } // TODO: is that right? + onSendEmail = { selected -> + editorInitialPreset = null + viewModel.setActiveShare(selected) + } ) } } @@ -171,6 +180,7 @@ private fun ShareScreen(sourceId: String, viewModel: ShareViewModel) { AddOrEditShareBottomSheet( share = it, viewModel = viewModel, + initialPresetOption = editorInitialPreset, onDismissDraft = { draftShare -> activeShare?.let { viewModel.deleteShare(draftShare.id) } viewModel.setActiveShare(null) @@ -184,6 +194,7 @@ private fun ShareItem( share: Share, type: ShareItemType, onSelectShare: (Share) -> Unit, + onCustomizeShare: (Share) -> Unit, onChangePreset: (Share, PermissionPreset) -> Unit, onDeleteShare: (Share) -> Unit, onSendEmail: (Share) -> Unit @@ -201,7 +212,7 @@ private fun ShareItem( if (preset != null) { onChangePreset(share, preset) } else { - onSelectShare(share) + onCustomizeShare(share) } }, onDismiss = { overlayState = ShareItemOverlayState.None } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 2472f823..c372c819 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -77,6 +77,7 @@ import kotlinx.coroutines.launch fun AddOrEditShareBottomSheet( share: Share, viewModel: ShareViewModel, + initialPresetOption: PermissionPresetOption? = null, onDismissDraft: (Share) -> Unit = {} ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -149,6 +150,7 @@ fun AddOrEditShareBottomSheet( PermissionsView( share = share, + initialPresetOption = initialPresetOption, viewModel = viewModel ) @@ -188,9 +190,12 @@ fun AddOrEditShareBottomSheet( @Composable private fun PermissionsView( share: Share, + initialPresetOption: PermissionPresetOption?, viewModel: ShareViewModel ) { - var selectedPreset by remember(share.id) { mutableStateOf(share.permissionPreset) } + var selectedPreset by remember(share.id) { + mutableStateOf(if (initialPresetOption != null) initialPresetOption.preset else share.permissionPreset) + } PermissionPresetDropdown( selectedOption = PermissionPresetOption.from(selectedPreset), From 9ab0cb3f14c9d3127279317ad74319b188713138 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 12:55:32 +0200 Subject: [PATCH 81/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index b1909300..c9f5d862 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -269,6 +270,7 @@ private fun ShareItem( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, ) + Spacer(modifier = Modifier.width(4.dp)) Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Down arrow") @@ -287,7 +289,10 @@ private fun ShareItem( ) { DropdownMenuItem( leadingIcon = { - Icon(Icons.Default.Edit, contentDescription = "Edit icon") + Icon(Icons.Default.Edit, + contentDescription = "Edit icon", + modifier = Modifier.size(18.dp) + ) }, text = { Text(stringResource(R.string.share_view_list_item_edit)) }, onClick = { @@ -297,7 +302,11 @@ private fun ShareItem( ) DropdownMenuItem( leadingIcon = { - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send icon") + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "Send icon", + modifier = Modifier.size(18.dp) + ) }, text = { Text(stringResource(R.string.share_view_list_item_send_email)) }, onClick = { @@ -308,7 +317,11 @@ private fun ShareItem( HorizontalDivider() DropdownMenuItem( leadingIcon = { - Icon(Icons.Default.Delete, contentDescription = "Delete icon") + Icon( + Icons.Default.Delete, + contentDescription = "Delete icon", + modifier = Modifier.size(18.dp) + ) }, text = { Text( From 39591fe95a0c12b23776cfe5e653df9d26e72808 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 13:12:00 +0200 Subject: [PATCH 82/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index c9f5d862..6c4d101f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -16,11 +16,13 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Delete @@ -254,15 +256,21 @@ private fun ShareItem( Text( text = headline, - style = MaterialTheme.typography.titleSmall + style = MaterialTheme.typography.titleMedium ) }, leadingContent = { share.recipients.first().icon?.let { RecipientIcon(icon = it) } }, supportingContent = { + val chipHorizontalPadding = 10.dp Row( - modifier = Modifier.clickable { overlayState = ShareItemOverlayState.QuickShare }, + modifier = Modifier + .offset(x = -chipHorizontalPadding) + .clip(RoundedCornerShape(percent = 50)) + .clickable { overlayState = ShareItemOverlayState.QuickShare } + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)) + .padding(horizontal = chipHorizontalPadding, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -271,11 +279,15 @@ private fun ShareItem( color = MaterialTheme.colorScheme.primary, ) - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(2.dp)) - Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Down arrow") + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) } - }, trailingContent = { Box { From 274ef85fed3798785a0b37b9a6b701c727894c70 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 13:58:03 +0200 Subject: [PATCH 83/95] wip Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 10 +++++----- .../android/common/ui/share/model/api/share/Share.kt | 3 +-- .../android/common/ui/share/model/ui/ShareCategory.kt | 11 ++++++++++- ui/src/main/res/values/strings.xml | 1 + 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index c372c819..01ae6c84 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -18,8 +18,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenuItem @@ -174,9 +172,11 @@ fun AddOrEditShareBottomSheet( ) } + // FIXME: make readyToSend suitable for anyone category if (share.readyToSend()) { ActionButtons( share = share, + category = selectedCategory, sendEnabled = !hasPropertyErrors, onSend = { viewModel.updateState(share.id, ShareState.ACTIVE) } ) @@ -287,10 +287,10 @@ private fun AdvancedSettingsSection( } } - @Composable private fun ActionButtons( share: Share, + category: ShareCategory, sendEnabled: Boolean, onSend: () -> Unit, ) { @@ -341,12 +341,12 @@ private fun ActionButtons( ), ) { Icon( - imageVector = Icons.AutoMirrored.Filled.Send, + imageVector = category.sendActionIcon, contentDescription = null, modifier = Modifier.size(20.dp), ) Text( - text = stringResource(R.string.share_view_send_action), + text = stringResource(category.sendActionTitleId), modifier = Modifier.padding(start = 8.dp), ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 33c22c8b..0c00667b 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -63,8 +63,7 @@ data class Share( return if (shareState == ShareState.DRAFT) { context.getString(R.string.share_view_bottom_sheet_new_title) } else { - // TODO do not hardcode - context.getString(R.string.share_view_bottom_sheet_edit_title, "") + context.getString(R.string.share_view_bottom_sheet_edit_title, sources.firstOrNull()?.displayName) } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt index e58af38d..44d78f9e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt @@ -7,6 +7,9 @@ package com.nextcloud.android.common.ui.share.model.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Share import com.nextcloud.android.common.ui.R enum class ShareCategory(val titleId: Int, val iconId: Int) { @@ -14,5 +17,11 @@ enum class ShareCategory(val titleId: Int, val iconId: Int) { R.string.share_view_invited_category_title, R.drawable.ic_person_add ), - Anyone(R.string.share_view_anyone_category_title, R.drawable.ic_anyone) + Anyone(R.string.share_view_anyone_category_title, R.drawable.ic_anyone); + + val sendActionTitleId + get() = if (this == Invited) R.string.share_view_send_action else R.string.share_view_share_action + + val sendActionIcon + get() = if (this == Invited) Icons.AutoMirrored.Filled.Send else Icons.Default.Share } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 0d432dd0..6f3f2b37 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ Copied Copy link Send + Share Edit Send email From 50b0d4cd8a26d243c795e498787869a840e158da Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 13:34:10 +0200 Subject: [PATCH 84/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/model/api/recipients/Recipient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt index 5d250b31..58d12ca0 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt @@ -32,6 +32,6 @@ data class Recipient( val initiator: User? = null ) { companion object { - const val TOKEN_RECIPIENT_CLASS = "OC\\CoreSharing\\Recipient\\TokenShareRecipientType" + const val TOKEN_RECIPIENT_CLASS = """\OC\Core\Sharing\Recipient\TokenShareRecipientType""" } } From ec5d1b6e76c948daf4152d6d54944b87f308b868 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 13:51:05 +0200 Subject: [PATCH 85/95] wip Signed-off-by: alperozturk96 --- .../common/ui/share/component/CustomLink.kt | 88 +++++++++++++++++++ .../ui/share/component/RecipientTokenField.kt | 41 --------- .../bottomsheet/AddOrEditShareBottomSheet.kt | 27 +++--- .../share/model/api/recipients/Recipient.kt | 2 +- ui/src/main/res/values/strings.xml | 5 +- 5 files changed, 107 insertions(+), 56 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientTokenField.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt new file mode 100644 index 00000000..b0e05afb --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt @@ -0,0 +1,88 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient +import java.util.UUID + +@Composable +fun CustomLink(recipient: Recipient, onTokenChange: (String) -> Unit) { + val prefix = remember(recipient.secret.url, recipient.secret.value) { + val url = recipient.secret.url.orEmpty() + val token = recipient.secret.value.orEmpty() + if (token.isNotEmpty() && url.endsWith(token)) url.removeSuffix(token) else url + } + var token by remember(recipient.value) { mutableStateOf(recipient.secret.value ?: "") } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = stringResource(R.string.share_view_custom_link_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = stringResource(R.string.share_view_custom_link_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp) + ) + + OutlinedTextField( + value = token, + onValueChange = { + token = it + onTokenChange(it) + }, + label = { + Text(prefix) + }, + singleLine = true, + trailingIcon = { + IconButton( + onClick = { + val refreshedToken = UUID.randomUUID().toString() + token = refreshedToken + onTokenChange(refreshedToken) + } + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(R.string.share_view_custom_link_refresh) + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientTokenField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientTokenField.kt deleted file mode 100644 index 11a7948d..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientTokenField.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.component - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.nextcloud.android.common.ui.R -import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient - -@Composable -fun RecipientTokenField(recipient: Recipient, onTokenChange: (String) -> Unit) { - var token by remember(recipient.value) { mutableStateOf(recipient.secret.value ?: "") } - - OutlinedTextField( - value = token, - onValueChange = { - token = it - onTokenChange(it) - }, - label = { Text(stringResource(R.string.share_view_link_token_label)) }, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - ) -} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 01ae6c84..1cc31dd8 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -52,7 +52,7 @@ import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel import com.nextcloud.android.common.ui.share.component.CollapsibleShareSection -import com.nextcloud.android.common.ui.share.component.RecipientTokenField +import com.nextcloud.android.common.ui.share.component.CustomLink import com.nextcloud.android.common.ui.share.component.SelectRecipientField import com.nextcloud.android.common.ui.share.component.ShareSwitch import com.nextcloud.android.common.ui.share.component.property.SharePropertyView @@ -135,26 +135,17 @@ fun AddOrEditShareBottomSheet( SelectRecipientField(share, viewModel) } - // TODO: is it going to be included for now? - val editableRecipient = share.recipients.firstOrNull { it.secret.updatable } - if (editableRecipient != null) { - RecipientTokenField( - recipient = editableRecipient, - onTokenChange = { token -> - viewModel.updateRecipientSecret(share.id, editableRecipient, token) - } - ) - } - PermissionsView( share = share, initialPresetOption = initialPresetOption, viewModel = viewModel ) - if (share.properties.isNotEmpty()) { + val editableRecipient = share.recipients.firstOrNull { it.secret.updatable } + if (share.properties.isNotEmpty() || editableRecipient != null) { AdvancedSettingsSection( share = share, + editableRecipient = editableRecipient, isExpanded = showAdvancedSettings, onToggle = { showAdvancedSettings = !showAdvancedSettings }, viewModel = viewModel @@ -266,6 +257,7 @@ private fun PermissionPresetDropdown( @Composable private fun AdvancedSettingsSection( share: Share, + editableRecipient: Recipient?, isExpanded: Boolean, onToggle: () -> Unit, viewModel: ShareViewModel @@ -284,6 +276,15 @@ private fun AdvancedSettingsSection( ) } } + + if (editableRecipient != null) { + CustomLink( + recipient = editableRecipient, + onTokenChange = { token -> + viewModel.updateRecipientSecret(share.id, editableRecipient, token) + } + ) + } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt index 58d12ca0..d5f967d5 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt @@ -32,6 +32,6 @@ data class Recipient( val initiator: User? = null ) { companion object { - const val TOKEN_RECIPIENT_CLASS = """\OC\Core\Sharing\Recipient\TokenShareRecipientType""" + const val TOKEN_RECIPIENT_CLASS = """OC\Core\Sharing\Recipient\TokenShareRecipientType""" } } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 6f3f2b37..be4b1939 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -15,9 +15,12 @@ Settings Recipient - Link token Clear recipient + Custom link + The link can be changed to be easy to remember, but do not set it to something that is easy to guess. + Refresh link + Not shared No result From 01d9ba98d04b68bb2aad49ac573aadb3eeb213ef Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 14:01:55 +0200 Subject: [PATCH 86/95] wip Signed-off-by: alperozturk96 --- .../common/ui/share/component/CustomLink.kt | 10 ++++----- .../ui/share/component/ShareTokenGenerator.kt | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareTokenGenerator.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt index b0e05afb..b28e4313 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient -import java.util.UUID @Composable fun CustomLink(recipient: Recipient, onTokenChange: (String) -> Unit) { @@ -60,9 +59,10 @@ fun CustomLink(recipient: Recipient, onTokenChange: (String) -> Unit) { OutlinedTextField( value = token, - onValueChange = { - token = it - onTokenChange(it) + onValueChange = { newValue -> + val trimmed = newValue.take(ShareTokenGenerator.MAX_LENGTH) + token = trimmed + if (trimmed.isNotBlank()) onTokenChange(trimmed) }, label = { Text(prefix) @@ -71,7 +71,7 @@ fun CustomLink(recipient: Recipient, onTokenChange: (String) -> Unit) { trailingIcon = { IconButton( onClick = { - val refreshedToken = UUID.randomUUID().toString() + val refreshedToken = ShareTokenGenerator.generate() token = refreshedToken onTokenChange(refreshedToken) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareTokenGenerator.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareTokenGenerator.kt new file mode 100644 index 00000000..3141da73 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareTokenGenerator.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component + +import java.security.SecureRandom + +object ShareTokenGenerator { + const val MAX_LENGTH = 32 + private const val DEFAULT_LENGTH = 15 + private const val HUMAN_READABLE_CHARS = "abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789" + + private val random = SecureRandom() + + fun generate(length: Int = DEFAULT_LENGTH): String = buildString { + repeat(length) { append(HUMAN_READABLE_CHARS[random.nextInt(HUMAN_READABLE_CHARS.length)]) } + } +} From 37613570e0d79b73ab4201377d54b42d98217d1d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 14:09:36 +0200 Subject: [PATCH 87/95] wip Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 18 ++++------ .../common/ui/share/model/api/share/Share.kt | 33 +++++++++++++------ 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 1cc31dd8..e711e888 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -141,11 +141,9 @@ fun AddOrEditShareBottomSheet( viewModel = viewModel ) - val editableRecipient = share.recipients.firstOrNull { it.secret.updatable } - if (share.properties.isNotEmpty() || editableRecipient != null) { + if (share.isAdvancedSettingsExists) { AdvancedSettingsSection( share = share, - editableRecipient = editableRecipient, isExpanded = showAdvancedSettings, onToggle = { showAdvancedSettings = !showAdvancedSettings }, viewModel = viewModel @@ -164,7 +162,7 @@ fun AddOrEditShareBottomSheet( } // FIXME: make readyToSend suitable for anyone category - if (share.readyToSend()) { + if (share.canSend) { ActionButtons( share = share, category = selectedCategory, @@ -257,7 +255,6 @@ private fun PermissionPresetDropdown( @Composable private fun AdvancedSettingsSection( share: Share, - editableRecipient: Recipient?, isExpanded: Boolean, onToggle: () -> Unit, viewModel: ShareViewModel @@ -277,11 +274,11 @@ private fun AdvancedSettingsSection( } } - if (editableRecipient != null) { + share.editableRecipient?.let { CustomLink( - recipient = editableRecipient, + recipient = it, onTokenChange = { token -> - viewModel.updateRecipientSecret(share.id, editableRecipient, token) + viewModel.updateRecipientSecret(share.id, it, token) } ) } @@ -301,15 +298,14 @@ private fun ActionButtons( .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - val clipEntry = share.getClipEntry() - if (clipEntry != null) { + if (share.clipEntry != null) { val localClipboard = LocalClipboard.current val scope = rememberCoroutineScope() Button( onClick = { scope.launch { - localClipboard.setClipEntry(clipEntry) + localClipboard.setClipEntry(share.clipEntry) } }, modifier = Modifier.weight(1f), diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 0c00667b..13b06b20 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -46,18 +46,31 @@ data class Share( val permissionPreset: PermissionPreset? = null ) { // FIXME: secret.url is null - fun getClipEntry(): ClipEntry? { - val recipient = recipients.firstOrNull() ?: return null - val link = recipient.secret.url ?: return null + val clipEntry: ClipEntry? + get() { + val recipient = recipients.firstOrNull() ?: return null + val link = recipient.secret.url ?: return null - return ClipData.newPlainText(recipient.displayName, link).toClipEntry() - } + return ClipData.newPlainText(recipient.displayName, link).toClipEntry() + } + + val editableRecipient: Recipient? + get() { + return recipients.firstOrNull { it.secret.updatable } + } - fun readyToSend(): Boolean = - sources.isNotEmpty() && - recipients.isNotEmpty() && - permissions.any { it.enabled } && - properties.none { it.required && it.value.isNullOrEmpty() } + val isAdvancedSettingsExists: Boolean + get() { + return properties.isNotEmpty() || editableRecipient != null + } + + val canSend: Boolean + get() { + return sources.isNotEmpty() && + recipients.isNotEmpty() && + permissions.any { it.enabled } && + properties.none { it.required && it.value.isNullOrEmpty() } + } fun title(context: Context): String { return if (shareState == ShareState.DRAFT) { From 95467c3aae263ea790bd09e0b10a417521f67b00 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 14:21:23 +0200 Subject: [PATCH 88/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 6 +++++ .../common/ui/share/component/CustomLink.kt | 21 +++++++++++++----- .../ui/share/component/ShareTokenGenerator.kt | 22 ------------------- .../bottomsheet/AddOrEditShareBottomSheet.kt | 2 ++ .../common/ui/share/model/api/share/Share.kt | 5 +++++ .../share/repository/MockShareRepository.kt | 2 ++ .../share/repository/ShareRemoteRepository.kt | 9 ++++++++ .../ui/share/repository/ShareRepository.kt | 2 ++ 8 files changed, 42 insertions(+), 27 deletions(-) delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareTokenGenerator.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 3fbceb82..1a657f81 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -253,6 +253,12 @@ class ShareViewModel( replaceInList(updated) } } + + suspend fun generateSecret(): String? = withContext(Dispatchers.IO) { + _errorMessageId.update { null } + val result = repository.generateSecret() + result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + } // endregion // region properties diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt index b28e4313..7231bf95 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -28,9 +29,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient +import kotlinx.coroutines.launch + +private const val MAX_TOKEN_LENGTH = 32 @Composable -fun CustomLink(recipient: Recipient, onTokenChange: (String) -> Unit) { +fun CustomLink( + recipient: Recipient, + onGenerateSecret: suspend () -> String?, + onTokenChange: (String) -> Unit +) { + val scope = rememberCoroutineScope() val prefix = remember(recipient.secret.url, recipient.secret.value) { val url = recipient.secret.url.orEmpty() val token = recipient.secret.value.orEmpty() @@ -60,7 +69,7 @@ fun CustomLink(recipient: Recipient, onTokenChange: (String) -> Unit) { OutlinedTextField( value = token, onValueChange = { newValue -> - val trimmed = newValue.take(ShareTokenGenerator.MAX_LENGTH) + val trimmed = newValue.take(MAX_TOKEN_LENGTH) token = trimmed if (trimmed.isNotBlank()) onTokenChange(trimmed) }, @@ -71,9 +80,11 @@ fun CustomLink(recipient: Recipient, onTokenChange: (String) -> Unit) { trailingIcon = { IconButton( onClick = { - val refreshedToken = ShareTokenGenerator.generate() - token = refreshedToken - onTokenChange(refreshedToken) + scope.launch { + val generated = onGenerateSecret() ?: return@launch + token = generated + onTokenChange(generated) + } } ) { Icon( diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareTokenGenerator.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareTokenGenerator.kt deleted file mode 100644 index 3141da73..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareTokenGenerator.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Nextcloud Android Common Library - * - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: MIT - */ - -package com.nextcloud.android.common.ui.share.component - -import java.security.SecureRandom - -object ShareTokenGenerator { - const val MAX_LENGTH = 32 - private const val DEFAULT_LENGTH = 15 - private const val HUMAN_READABLE_CHARS = "abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789" - - private val random = SecureRandom() - - fun generate(length: Int = DEFAULT_LENGTH): String = buildString { - repeat(length) { append(HUMAN_READABLE_CHARS[random.nextInt(HUMAN_READABLE_CHARS.length)]) } - } -} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index e711e888..3fc7c166 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -117,6 +117,7 @@ fun AddOrEditShareBottomSheet( categories.forEachIndexed { index, category -> SegmentedButton( selected = selectedCategory == category, + enabled = category != ShareCategory.Anyone || !share.hasInvitedRecipients, onClick = { selectedCategory = category viewModel.addAnyoneRecipient(category, share) @@ -277,6 +278,7 @@ private fun AdvancedSettingsSection( share.editableRecipient?.let { CustomLink( recipient = it, + onGenerateSecret = { viewModel.generateSecret() }, onTokenChange = { token -> viewModel.updateRecipientSecret(share.id, it, token) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 13b06b20..f340809c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -59,6 +59,11 @@ data class Share( return recipients.firstOrNull { it.secret.updatable } } + val hasInvitedRecipients: Boolean + get() { + return recipients.any { it.clazz != Recipient.TOKEN_RECIPIENT_CLASS } + } + val isAdvancedSettingsExists: Boolean get() { return properties.isNotEmpty() || editableRecipient != null diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt index f125d92c..96c43283 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -434,6 +434,8 @@ class MockShareRepository : ShareRepository { return NetworkResult.Success(current) } + override suspend fun generateSecret(): NetworkResult = NetworkResult.Success("mockSecret12345") + override suspend fun updateSharePermission( id: String, request: UpdateSharePermissionRequest diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt index 7e09a2f5..71eccd8f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -34,6 +34,7 @@ class ShareRemoteRepository( private const val SHARE_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/share" private const val SHARES_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/shares" private const val RECIPIENTS_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/recipients" + private const val SECRET_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/secret" } override suspend fun fetchRecipients( @@ -212,4 +213,12 @@ class ShareRemoteRepository( ) { body -> json.decodeFromString>(body).ocs.data } + + override suspend fun generateSecret(): NetworkResult = + client.executeRequest( + endpoint = SECRET_ENDPOINT, + method = HttpMethod.GET + ) { body -> + json.decodeFromString>(body).ocs.data + } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt index 9ed354e0..51bd49d2 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt @@ -89,4 +89,6 @@ interface ShareRepository { id: String, request: UpdateShareRecipientSecretRequest ): NetworkResult + + suspend fun generateSecret(): NetworkResult } From 0bcb4cd5515e8a49a821ca7e36af726300041fdb Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 14:52:44 +0200 Subject: [PATCH 89/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 35 +++++++++++-------- .../common/ui/share/component/CustomLink.kt | 4 +-- .../bottomsheet/AddOrEditShareBottomSheet.kt | 10 +++--- .../common/ui/share/model/api/share/Share.kt | 10 ------ 4 files changed, 28 insertions(+), 31 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 1a657f81..da84e523 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -207,25 +207,32 @@ class ShareViewModel( } } - fun addAnyoneRecipient(category: ShareCategory, share: Share) { - if (category != ShareCategory.Anyone) return - - share.recipients - .filter { it.clazz != Recipient.TOKEN_RECIPIENT_CLASS } - .forEach { recipient -> - removeRecipient(share.id, recipient.clazz, recipient.value, recipient.instance) + fun selectCategory(category: ShareCategory, share: Share) { + when (category) { + ShareCategory.Anyone -> { + removeRecipients(share) { it.clazz != Recipient.TOKEN_RECIPIENT_CLASS } + if (share.recipients.none { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS }) { + addRecipient( + id = share.id, + clazz = Recipient.TOKEN_RECIPIENT_CLASS, + value = UUID.randomUUID().toString(), + instance = null + ) + } } - if (share.recipients.none { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS }) { - addRecipient( - id = share.id, - clazz = Recipient.TOKEN_RECIPIENT_CLASS, - value = UUID.randomUUID().toString(), - instance = null - ) + ShareCategory.Invited -> { + removeRecipients(share) { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS } + } } } + private fun removeRecipients(share: Share, predicate: (Recipient) -> Boolean) { + share.recipients + .filter(predicate) + .forEach { removeRecipient(share.id, it.clazz, it.value, it.instance) } + } + fun removeRecipient(id: String, clazz: String, value: String, instance: String? = null) { viewModelScope.launch(Dispatchers.IO) { val result = repository.removeShareRecipient(id, clazz, value, instance) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt index 7231bf95..8c581c52 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt @@ -73,9 +73,7 @@ fun CustomLink( token = trimmed if (trimmed.isNotBlank()) onTokenChange(trimmed) }, - label = { - Text(prefix) - }, + label = { Text(prefix) }, singleLine = true, trailingIcon = { IconButton( diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 3fc7c166..a0e2c722 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -117,10 +117,9 @@ fun AddOrEditShareBottomSheet( categories.forEachIndexed { index, category -> SegmentedButton( selected = selectedCategory == category, - enabled = category != ShareCategory.Anyone || !share.hasInvitedRecipients, onClick = { selectedCategory = category - viewModel.addAnyoneRecipient(category, share) + viewModel.selectCategory(category, share) }, shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size), icon = { @@ -142,9 +141,11 @@ fun AddOrEditShareBottomSheet( viewModel = viewModel ) - if (share.isAdvancedSettingsExists) { + val editableRecipient = share.editableRecipient + if (share.properties.isNotEmpty() || editableRecipient != null) { AdvancedSettingsSection( share = share, + editableRecipient = editableRecipient, isExpanded = showAdvancedSettings, onToggle = { showAdvancedSettings = !showAdvancedSettings }, viewModel = viewModel @@ -256,6 +257,7 @@ private fun PermissionPresetDropdown( @Composable private fun AdvancedSettingsSection( share: Share, + editableRecipient: Recipient?, isExpanded: Boolean, onToggle: () -> Unit, viewModel: ShareViewModel @@ -275,7 +277,7 @@ private fun AdvancedSettingsSection( } } - share.editableRecipient?.let { + editableRecipient?.let { CustomLink( recipient = it, onGenerateSecret = { viewModel.generateSecret() }, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index f340809c..5cca8b4c 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -59,16 +59,6 @@ data class Share( return recipients.firstOrNull { it.secret.updatable } } - val hasInvitedRecipients: Boolean - get() { - return recipients.any { it.clazz != Recipient.TOKEN_RECIPIENT_CLASS } - } - - val isAdvancedSettingsExists: Boolean - get() { - return properties.isNotEmpty() || editableRecipient != null - } - val canSend: Boolean get() { return sources.isNotEmpty() && From 34ebf2c525d41dc07bec7f0c187c06a9d0615688 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 15:42:25 +0200 Subject: [PATCH 90/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 5 ++- .../android/common/ui/share/ShareViewModel.kt | 10 +++++- .../bottomsheet/AddOrEditShareBottomSheet.kt | 35 ++++++++++--------- .../common/ui/share/model/api/share/Share.kt | 25 ++++++++----- 4 files changed, 48 insertions(+), 27 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index 6c4d101f..e7eef100 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -355,10 +355,13 @@ private fun ShareItem( fun ComposeView.initShareScreen( sourceId: String, credentials: ServerCredentials, + internalLink: String, colorScheme: ColorScheme ) { val nextcloudHttpClient = NextcloudHttpClient.create(credentials) - val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)) + val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)).apply { + setInternalLink(internalLink) + } setContent { setSingletonImageLoaderFactory { context -> diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index da84e523..7d6a0c99 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -12,11 +12,11 @@ import androidx.lifecycle.viewModelScope import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.network.model.NetworkResult import com.nextcloud.android.common.ui.network.model.dataOrElse +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest -import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionPresetRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest @@ -71,6 +71,8 @@ class ShareViewModel( private val propertyUpdateJobs = mutableMapOf() + private var internalLink: String = "" + private var secretUpdateJob: Job? = null private val currentShares: List @@ -185,6 +187,12 @@ class ShareViewModel( } } + fun setInternalLink(value: String) { + internalLink = value + } + + fun getInternalLink(): String = internalLink + fun removeSource(id: String, clazz: String, value: String) { viewModelScope.launch(Dispatchers.IO) { val result = repository.removeShareSource(id, clazz, value) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index a0e2c722..2cf59444 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -141,16 +141,13 @@ fun AddOrEditShareBottomSheet( viewModel = viewModel ) - val editableRecipient = share.editableRecipient - if (share.properties.isNotEmpty() || editableRecipient != null) { - AdvancedSettingsSection( - share = share, - editableRecipient = editableRecipient, - isExpanded = showAdvancedSettings, - onToggle = { showAdvancedSettings = !showAdvancedSettings }, - viewModel = viewModel - ) - } + AdvancedSettingsSection( + share = share, + category = selectedCategory, + isExpanded = showAdvancedSettings, + onToggle = { showAdvancedSettings = !showAdvancedSettings }, + viewModel = viewModel + ) if (hasPropertyErrors) { Text( @@ -163,11 +160,11 @@ fun AddOrEditShareBottomSheet( ) } - // FIXME: make readyToSend suitable for anyone category if (share.canSend) { ActionButtons( share = share, category = selectedCategory, + internalLink = viewModel.getInternalLink(), sendEnabled = !hasPropertyErrors, onSend = { viewModel.updateState(share.id, ShareState.ACTIVE) } ) @@ -257,7 +254,7 @@ private fun PermissionPresetDropdown( @Composable private fun AdvancedSettingsSection( share: Share, - editableRecipient: Recipient?, + category: ShareCategory, isExpanded: Boolean, onToggle: () -> Unit, viewModel: ShareViewModel @@ -277,12 +274,16 @@ private fun AdvancedSettingsSection( } } - editableRecipient?.let { + share.getCustomLinkRecipient(category)?.let { CustomLink( recipient = it, onGenerateSecret = { viewModel.generateSecret() }, onTokenChange = { token -> - viewModel.updateRecipientSecret(share.id, it, token) + var recipient = it + if (category == ShareCategory.Invited) { + recipient = it.copy(value = token) + } + viewModel.updateRecipientSecret(share.id, recipient, token) } ) } @@ -293,6 +294,7 @@ private fun AdvancedSettingsSection( private fun ActionButtons( share: Share, category: ShareCategory, + internalLink: String, sendEnabled: Boolean, onSend: () -> Unit, ) { @@ -302,14 +304,15 @@ private fun ActionButtons( .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - if (share.clipEntry != null) { + val clipEntry = share.getClipEntry(category, internalLink) + if (clipEntry != null) { val localClipboard = LocalClipboard.current val scope = rememberCoroutineScope() Button( onClick = { scope.launch { - localClipboard.setClipEntry(share.clipEntry) + localClipboard.setClipEntry(clipEntry) } }, modifier = Modifier.weight(1f), diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 5cca8b4c..7aadd098 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -19,6 +19,7 @@ import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.source.Source import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.model.api.user.User +import com.nextcloud.android.common.ui.share.model.ui.ShareCategory import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -45,19 +46,25 @@ data class Share( @SerialName("permission_preset") val permissionPreset: PermissionPreset? = null ) { - // FIXME: secret.url is null - val clipEntry: ClipEntry? - get() { - val recipient = recipients.firstOrNull() ?: return null - val link = recipient.secret.url ?: return null + fun getClipEntry(category: ShareCategory, internalLink: String): ClipEntry? { + val recipient = recipients.firstOrNull() ?: return null - return ClipData.newPlainText(recipient.displayName, link).toClipEntry() + return if (category == ShareCategory.Anyone) { + val link = recipient.secret.url ?: return null + ClipData.newPlainText(recipient.displayName, link).toClipEntry() + } else { + ClipData.newPlainText(recipient.displayName, internalLink).toClipEntry() } + } - val editableRecipient: Recipient? - get() { - return recipients.firstOrNull { it.secret.updatable } + fun getCustomLinkRecipient(category: ShareCategory): Recipient? { + return if (category == ShareCategory.Anyone) { + recipients.firstOrNull { it.secret.updatable } + } else { + val firstRecipient = recipients.firstOrNull() + firstRecipient?.copy(clazz = Recipient.TOKEN_RECIPIENT_CLASS) } + } val canSend: Boolean get() { From 65ba5189d257ea6c101bc99cbb8cc66eaf39d21d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 16:01:32 +0200 Subject: [PATCH 91/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 5 +-- .../android/common/ui/share/ShareViewModel.kt | 8 ----- .../bottomsheet/AddOrEditShareBottomSheet.kt | 31 +++++++------------ .../common/ui/share/model/api/share/Share.kt | 23 +++++--------- 4 files changed, 20 insertions(+), 47 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index e7eef100..6c4d101f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -355,13 +355,10 @@ private fun ShareItem( fun ComposeView.initShareScreen( sourceId: String, credentials: ServerCredentials, - internalLink: String, colorScheme: ColorScheme ) { val nextcloudHttpClient = NextcloudHttpClient.create(credentials) - val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)).apply { - setInternalLink(internalLink) - } + val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)) setContent { setSingletonImageLoaderFactory { context -> diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 7d6a0c99..8d0c5271 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -71,8 +71,6 @@ class ShareViewModel( private val propertyUpdateJobs = mutableMapOf() - private var internalLink: String = "" - private var secretUpdateJob: Job? = null private val currentShares: List @@ -187,12 +185,6 @@ class ShareViewModel( } } - fun setInternalLink(value: String) { - internalLink = value - } - - fun getInternalLink(): String = internalLink - fun removeSource(id: String, clazz: String, value: String) { viewModelScope.launch(Dispatchers.IO) { val result = repository.removeShareSource(id, clazz, value) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 2cf59444..d16d4f02 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -141,13 +141,14 @@ fun AddOrEditShareBottomSheet( viewModel = viewModel ) - AdvancedSettingsSection( - share = share, - category = selectedCategory, - isExpanded = showAdvancedSettings, - onToggle = { showAdvancedSettings = !showAdvancedSettings }, - viewModel = viewModel - ) + if (share.properties.isNotEmpty() || share.customLinkRecipient != null) { + AdvancedSettingsSection( + share = share, + isExpanded = showAdvancedSettings, + onToggle = { showAdvancedSettings = !showAdvancedSettings }, + viewModel = viewModel + ) + } if (hasPropertyErrors) { Text( @@ -164,7 +165,6 @@ fun AddOrEditShareBottomSheet( ActionButtons( share = share, category = selectedCategory, - internalLink = viewModel.getInternalLink(), sendEnabled = !hasPropertyErrors, onSend = { viewModel.updateState(share.id, ShareState.ACTIVE) } ) @@ -254,7 +254,6 @@ private fun PermissionPresetDropdown( @Composable private fun AdvancedSettingsSection( share: Share, - category: ShareCategory, isExpanded: Boolean, onToggle: () -> Unit, viewModel: ShareViewModel @@ -274,16 +273,12 @@ private fun AdvancedSettingsSection( } } - share.getCustomLinkRecipient(category)?.let { + share.customLinkRecipient?.let { CustomLink( recipient = it, onGenerateSecret = { viewModel.generateSecret() }, onTokenChange = { token -> - var recipient = it - if (category == ShareCategory.Invited) { - recipient = it.copy(value = token) - } - viewModel.updateRecipientSecret(share.id, recipient, token) + viewModel.updateRecipientSecret(share.id, it, token) } ) } @@ -294,7 +289,6 @@ private fun AdvancedSettingsSection( private fun ActionButtons( share: Share, category: ShareCategory, - internalLink: String, sendEnabled: Boolean, onSend: () -> Unit, ) { @@ -304,15 +298,14 @@ private fun ActionButtons( .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - val clipEntry = share.getClipEntry(category, internalLink) - if (clipEntry != null) { + if (share.clipEntry != null) { val localClipboard = LocalClipboard.current val scope = rememberCoroutineScope() Button( onClick = { scope.launch { - localClipboard.setClipEntry(clipEntry) + localClipboard.setClipEntry(share.clipEntry) } }, modifier = Modifier.weight(1f), diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 7aadd098..b7f4e780 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -19,7 +19,6 @@ import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.source.Source import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.model.api.user.User -import com.nextcloud.android.common.ui.share.model.ui.ShareCategory import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -46,25 +45,17 @@ data class Share( @SerialName("permission_preset") val permissionPreset: PermissionPreset? = null ) { - fun getClipEntry(category: ShareCategory, internalLink: String): ClipEntry? { - val recipient = recipients.firstOrNull() ?: return null - - return if (category == ShareCategory.Anyone) { + val clipEntry: ClipEntry? + get() { + val recipient = recipients.firstOrNull() ?: return null val link = recipient.secret.url ?: return null - ClipData.newPlainText(recipient.displayName, link).toClipEntry() - } else { - ClipData.newPlainText(recipient.displayName, internalLink).toClipEntry() + return ClipData.newPlainText(recipient.displayName, link).toClipEntry() } - } - fun getCustomLinkRecipient(category: ShareCategory): Recipient? { - return if (category == ShareCategory.Anyone) { - recipients.firstOrNull { it.secret.updatable } - } else { - val firstRecipient = recipients.firstOrNull() - firstRecipient?.copy(clazz = Recipient.TOKEN_RECIPIENT_CLASS) + val customLinkRecipient: Recipient? + get() { + return recipients.firstOrNull { it.secret.updatable } } - } val canSend: Boolean get() { From b4740163049f00a6934a62bc9c3ff33ce070f58c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 8 Jul 2026 15:10:48 +0200 Subject: [PATCH 92/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 6 +- .../bottomsheet/AddOrEditShareBottomSheet.kt | 66 ++++++++++++------- .../common/ui/share/model/api/share/Share.kt | 13 ++-- .../common/ui/share/model/ui/ShareCategory.kt | 3 + ui/src/main/res/values/strings.xml | 5 +- 5 files changed, 63 insertions(+), 30 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index 6c4d101f..d5b80df5 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -85,7 +85,7 @@ import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch @Composable -private fun ShareScreen(sourceId: String, viewModel: ShareViewModel) { +private fun ShareScreen(sourceId: String, internalLink: String, viewModel: ShareViewModel) { val errorMessageId by viewModel.errorMessageId.collectAsState() val screenState by viewModel.state.collectAsState() val activeShare by viewModel.activeShare.collectAsState() @@ -182,6 +182,7 @@ private fun ShareScreen(sourceId: String, viewModel: ShareViewModel) { activeShare?.let { AddOrEditShareBottomSheet( share = it, + internalLink = internalLink, viewModel = viewModel, initialPresetOption = editorInitialPreset, onDismissDraft = { draftShare -> @@ -354,6 +355,7 @@ private fun ShareItem( fun ComposeView.initShareScreen( sourceId: String, + internalLink: String, credentials: ServerCredentials, colorScheme: ColorScheme ) { @@ -370,7 +372,7 @@ fun ComposeView.initShareScreen( MaterialTheme( colorScheme = colorScheme, content = { - ShareScreen(sourceId, viewModel) + ShareScreen(sourceId, internalLink, viewModel) } ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index d16d4f02..45201dff 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -74,6 +74,7 @@ import kotlinx.coroutines.launch @Composable fun AddOrEditShareBottomSheet( share: Share, + internalLink: String, viewModel: ShareViewModel, initialPresetOption: PermissionPresetOption? = null, onDismissDraft: (Share) -> Unit = {} @@ -164,6 +165,7 @@ fun AddOrEditShareBottomSheet( if (share.canSend) { ActionButtons( share = share, + internalLink = internalLink, category = selectedCategory, sendEnabled = !hasPropertyErrors, onSend = { viewModel.updateState(share.id, ShareState.ACTIVE) } @@ -288,6 +290,7 @@ private fun AdvancedSettingsSection( @Composable private fun ActionButtons( share: Share, + internalLink: String, category: ShareCategory, sendEnabled: Boolean, onSend: () -> Unit, @@ -298,14 +301,15 @@ private fun ActionButtons( .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - if (share.clipEntry != null) { + val clipEntry = share.getClipEntry(internalLink, category) + if (clipEntry != null) { val localClipboard = LocalClipboard.current val scope = rememberCoroutineScope() Button( onClick = { scope.launch { - localClipboard.setClipEntry(share.clipEntry) + localClipboard.setClipEntry(clipEntry) } }, modifier = Modifier.weight(1f), @@ -321,31 +325,48 @@ private fun ActionButtons( modifier = Modifier.size(20.dp), ) Text( - text = stringResource(R.string.share_view_copy_action), + text = stringResource(category.copyLinkTitleId), modifier = Modifier.padding(start = 8.dp), ) } } - Button( - onClick = onSend, - enabled = sendEnabled, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), - ) { - Icon( - imageVector = category.sendActionIcon, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource(category.sendActionTitleId), - modifier = Modifier.padding(start = 8.dp), - ) + if (category == ShareCategory.Invited) { + Button( + onClick = onSend, + enabled = sendEnabled, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Icon( + imageVector = category.sendActionIcon, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(category.sendActionTitleId), + modifier = Modifier.padding(start = 8.dp), + ) + } + } else { + Button( + onClick = onSend, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Text( + text = stringResource(category.sendActionTitleId), + modifier = Modifier.padding(start = 8.dp), + ) + } } } } @@ -356,6 +377,7 @@ private fun AddOrEditShareBottomSheetPreview() { MaterialTheme { AddOrEditShareBottomSheet( share = previewShare, + internalLink = "internal_link", viewModel = ShareViewModel(MockShareRepository()) ) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index b7f4e780..9b8b2ecb 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -19,6 +19,7 @@ import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.source.Source import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.model.api.user.User +import com.nextcloud.android.common.ui.share.model.ui.ShareCategory import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -45,12 +46,16 @@ data class Share( @SerialName("permission_preset") val permissionPreset: PermissionPreset? = null ) { - val clipEntry: ClipEntry? - get() { - val recipient = recipients.firstOrNull() ?: return null + fun getClipEntry(internalLink: String, category: ShareCategory): ClipEntry? { + val recipient = recipients.firstOrNull() ?: return null + + return if (category == ShareCategory.Anyone) { val link = recipient.secret.url ?: return null - return ClipData.newPlainText(recipient.displayName, link).toClipEntry() + ClipData.newPlainText(recipient.displayName, link).toClipEntry() + } else { + ClipData.newPlainText(recipient.displayName, internalLink).toClipEntry() } + } val customLinkRecipient: Recipient? get() { diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt index 44d78f9e..6522188e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt @@ -19,6 +19,9 @@ enum class ShareCategory(val titleId: Int, val iconId: Int) { ), Anyone(R.string.share_view_anyone_category_title, R.drawable.ic_anyone); + val copyLinkTitleId + get() = if (this == Invited) R.string.share_view_invited_copy_action else R.string.share_view_anyone_copy_action + val sendActionTitleId get() = if (this == Invited) R.string.share_view_send_action else R.string.share_view_share_action diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index be4b1939..063b4096 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -25,9 +25,10 @@ No result Copied - Copy link + Copy private link + Copy public link Send - Share + Share public link… Edit Send email From 2f407f729207e47824969d73019ab026486742de Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 8 Jul 2026 15:16:36 +0200 Subject: [PATCH 93/95] wip Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 45201dff..699e6fbb 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -301,34 +301,33 @@ private fun ActionButtons( .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - val clipEntry = share.getClipEntry(internalLink, category) - if (clipEntry != null) { - val localClipboard = LocalClipboard.current - val scope = rememberCoroutineScope() + val localClipboard = LocalClipboard.current + val scope = rememberCoroutineScope() - Button( - onClick = { - scope.launch { - localClipboard.setClipEntry(clipEntry) + Button( + onClick = { + scope.launch { + share.getClipEntry(internalLink, category)?.let { + localClipboard.setClipEntry(it) } - }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Icon( - painter = painterResource(R.drawable.ic_link), - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Text( - text = stringResource(category.copyLinkTitleId), - modifier = Modifier.padding(start = 8.dp), - ) - } + } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_link), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(category.copyLinkTitleId), + modifier = Modifier.padding(start = 8.dp), + ) } if (category == ShareCategory.Invited) { From d7083e5f960be45ca2a92659c8096664ec748c51 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 8 Jul 2026 15:40:12 +0200 Subject: [PATCH 94/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 29 +++++++----- .../android/common/ui/share/ShareViewModel.kt | 46 ++++++++++++------- .../bottomsheet/AddOrEditShareBottomSheet.kt | 25 ++++++++-- .../common/ui/share/model/api/share/Share.kt | 5 ++ .../ui/share/model/ui/ActiveShareState.kt | 16 +++++++ 5 files changed, 89 insertions(+), 32 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ActiveShareState.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index d5b80df5..c77e0dd3 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -76,6 +76,7 @@ import com.nextcloud.android.common.ui.share.component.bottomsheet.QuickSharePer import com.nextcloud.android.common.ui.share.component.dialog.DeleteShareConfirmationDialog import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.ui.ActiveShareState import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption import com.nextcloud.android.common.ui.share.model.ui.ShareItemOverlayState import com.nextcloud.android.common.ui.share.model.ui.ShareItemType @@ -179,17 +180,23 @@ private fun ShareScreen(sourceId: String, internalLink: String, viewModel: Share } } - activeShare?.let { - AddOrEditShareBottomSheet( - share = it, - internalLink = internalLink, - viewModel = viewModel, - initialPresetOption = editorInitialPreset, - onDismissDraft = { draftShare -> - activeShare?.let { viewModel.deleteShare(draftShare.id) } - viewModel.setActiveShare(null) - } - ) + if (activeShare !is ActiveShareState.Dismiss) { + val share = activeShare as? ActiveShareState.SharedAndDismiss + val share2 = activeShare as? ActiveShareState.Update + val activeShareObject = share?.value ?: share2?.value + + activeShareObject?.let { + AddOrEditShareBottomSheet( + share = it, + internalLink = internalLink, + viewModel = viewModel, + initialPresetOption = editorInitialPreset, + onDismissDraft = { draftShare -> + activeShare.let { viewModel.deleteShare(draftShare.id) } + viewModel.setActiveShare(null) + } + ) + } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 8d0c5271..299e05ae 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -24,6 +24,7 @@ import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipi import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest import com.nextcloud.android.common.ui.share.model.api.share.Share import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.ui.ActiveShareState import com.nextcloud.android.common.ui.share.model.ui.ShareCategory import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState import com.nextcloud.android.common.ui.share.model.ui.filtered @@ -55,8 +56,8 @@ class ShareViewModel( private val _state = MutableStateFlow(ShareScreenState.Loading) val state: StateFlow = _state - private val _activeShare = MutableStateFlow(null) - val activeShare: StateFlow = _activeShare + private val _activeShare = MutableStateFlow(ActiveShareState.Dismiss) + val activeShare: StateFlow = _activeShare private val _searchQuery = MutableStateFlow("") @@ -133,7 +134,7 @@ class ShareViewModel( val share = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } ?: return@launch - _activeShare.update { share } + _activeShare.update { share.toActiveShare() } replaceInList(share) } } @@ -148,7 +149,7 @@ class ShareViewModel( val draft = result.dataOrElse { _errorMessageId.update { R.string.share_view_create_error_message } } if (draft != null) { - _activeShare.update { draft } + _activeShare.update { draft.toActiveShare() } _state.update { ShareScreenState.Loaded(listOf(draft) + currentShares) } } @@ -157,15 +158,19 @@ class ShareViewModel( // endregion // region state - fun updateState(id: String, shareState: ShareState) { + fun updateState(id: String, shareState: ShareState, updateAndDontDismiss: Boolean = false) { viewModelScope.launch(Dispatchers.IO) { val result = repository.updateShareState(id, UpdateShareStateRequest(shareState)) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch if (shareState == ShareState.ACTIVE) { - _activeShare.update { null } + if (updateAndDontDismiss) { + _activeShare.update { ActiveShareState.Update(updated) } + } else { + _activeShare.update { ActiveShareState.Dismiss } + } } else { - _activeShare.update { updated } + _activeShare.update { updated.toActiveShare() } } replaceInList(updated) } @@ -180,7 +185,7 @@ class ShareViewModel( val result = repository.addShareSource(id, AddSourceRequest(clazz, value)) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch - _activeShare.update { updated } + _activeShare.update { updated.toActiveShare() } replaceInList(updated) } } @@ -190,7 +195,7 @@ class ShareViewModel( val result = repository.removeShareSource(id, clazz, value) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch - _activeShare.update { updated } + _activeShare.update { updated.toActiveShare() } replaceInList(updated) } } @@ -202,7 +207,7 @@ class ShareViewModel( val result = repository.addShareRecipient(id, AddRecipientRequest(clazz, value, instance)) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch - _activeShare.update { updated } + _activeShare.update { updated.toActiveShare() } replaceInList(updated) } } @@ -238,7 +243,7 @@ class ShareViewModel( val result = repository.removeShareRecipient(id, clazz, value, instance) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch - _activeShare.update { updated } + _activeShare.update { updated.toActiveShare() } replaceInList(updated) } } @@ -256,7 +261,7 @@ class ShareViewModel( val result = repository.updateShareRecipientSecret(shareId, request) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch - _activeShare.update { updated } + _activeShare.update { updated.toActiveShare() } replaceInList(updated) } } @@ -276,7 +281,7 @@ class ShareViewModel( when (val result = repository.updateShareProperty(shareId, UpdateSharePropertyRequest(clazz, value))) { is NetworkResult.Success -> { _propertyErrors.update { it - clazz } - _activeShare.update { result.data } + _activeShare.update { result.data.toActiveShare() } replaceInList(result.data) } @@ -308,7 +313,7 @@ class ShareViewModel( val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch if (updateActiveShare) { - _activeShare.update { updated } + _activeShare.update { updated.toActiveShare() } } replaceInList(updated) } @@ -326,7 +331,13 @@ class ShareViewModel( if (remaining.filtered().isEmpty()) ShareScreenState.Empty else ShareScreenState.Loaded(remaining) } - if (_activeShare.value?.id == id) _activeShare.update { null } + + if (_activeShare.value is ActiveShareState.SharedAndDismiss) { + val sharedAndDismiss = (_activeShare.value as ActiveShareState.SharedAndDismiss) + if (sharedAndDismiss.value.id == id) { + _activeShare.update { ActiveShareState.Dismiss } + } + } } } // endregion @@ -338,7 +349,10 @@ class ShareViewModel( fun setActiveShare(value: Share?) { _propertyErrors.update { emptyMap() } - _activeShare.update { value } + + value?.let { + _activeShare.update { value.toActiveShare() } + } } // endregion diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt index 699e6fbb..e86484a5 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -168,7 +168,7 @@ fun AddOrEditShareBottomSheet( internalLink = internalLink, category = selectedCategory, sendEnabled = !hasPropertyErrors, - onSend = { viewModel.updateState(share.id, ShareState.ACTIVE) } + viewModel = viewModel ) } } @@ -293,7 +293,7 @@ private fun ActionButtons( internalLink: String, category: ShareCategory, sendEnabled: Boolean, - onSend: () -> Unit, + viewModel: ShareViewModel ) { Row( modifier = Modifier @@ -308,7 +308,18 @@ private fun ActionButtons( onClick = { scope.launch { share.getClipEntry(internalLink, category)?.let { - localClipboard.setClipEntry(it) + if (category == ShareCategory.Anyone) { + + // recompose ActionButtons + viewModel.updateState(share.id, ShareState.ACTIVE, updateAndDontDismiss = true) + + // share object is updated active share + if (share.shareState == ShareState.ACTIVE) { + localClipboard.setClipEntry(it) + } + } else { + localClipboard.setClipEntry(it) + } } } }, @@ -332,7 +343,9 @@ private fun ActionButtons( if (category == ShareCategory.Invited) { Button( - onClick = onSend, + onClick = { + viewModel.updateState(share.id, ShareState.ACTIVE) + }, enabled = sendEnabled, modifier = Modifier.weight(1f), shape = RoundedCornerShape(10.dp), @@ -353,7 +366,9 @@ private fun ActionButtons( } } else { Button( - onClick = onSend, + onClick = { + viewModel.updateState(share.id, ShareState.ACTIVE) + }, modifier = Modifier.weight(1f), shape = RoundedCornerShape(10.dp), colors = ButtonDefaults.buttonColors( diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index 9b8b2ecb..f44a2d10 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -19,6 +19,7 @@ import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient import com.nextcloud.android.common.ui.share.model.api.source.Source import com.nextcloud.android.common.ui.share.model.api.state.ShareState import com.nextcloud.android.common.ui.share.model.api.user.User +import com.nextcloud.android.common.ui.share.model.ui.ActiveShareState import com.nextcloud.android.common.ui.share.model.ui.ShareCategory import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -46,6 +47,10 @@ data class Share( @SerialName("permission_preset") val permissionPreset: PermissionPreset? = null ) { + fun toActiveShare(): ActiveShareState { + return ActiveShareState.SharedAndDismiss(this) + } + fun getClipEntry(internalLink: String, category: ShareCategory): ClipEntry? { val recipient = recipients.firstOrNull() ?: return null diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ActiveShareState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ActiveShareState.kt new file mode 100644 index 00000000..9c6f6884 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ActiveShareState.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.ui + +import com.nextcloud.android.common.ui.share.model.api.share.Share + +sealed class ActiveShareState { + data object Dismiss: ActiveShareState() + data class Update(val value: Share): ActiveShareState() + data class SharedAndDismiss(val value: Share): ActiveShareState() +} From 7cd5bbb2e9fdfced4dc189762e9b1cc7bdc70641 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 8 Jul 2026 15:49:28 +0200 Subject: [PATCH 95/95] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 31 +++++++------------ .../android/common/ui/share/ShareViewModel.kt | 14 ++++----- .../common/ui/share/model/api/share/Share.kt | 2 +- .../ui/share/model/ui/ActiveShareState.kt | 13 ++++++-- 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt index c77e0dd3..f5411ae4 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -76,7 +76,6 @@ import com.nextcloud.android.common.ui.share.component.bottomsheet.QuickSharePer import com.nextcloud.android.common.ui.share.component.dialog.DeleteShareConfirmationDialog import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset import com.nextcloud.android.common.ui.share.model.api.share.Share -import com.nextcloud.android.common.ui.share.model.ui.ActiveShareState import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption import com.nextcloud.android.common.ui.share.model.ui.ShareItemOverlayState import com.nextcloud.android.common.ui.share.model.ui.ShareItemType @@ -180,23 +179,17 @@ private fun ShareScreen(sourceId: String, internalLink: String, viewModel: Share } } - if (activeShare !is ActiveShareState.Dismiss) { - val share = activeShare as? ActiveShareState.SharedAndDismiss - val share2 = activeShare as? ActiveShareState.Update - val activeShareObject = share?.value ?: share2?.value - - activeShareObject?.let { - AddOrEditShareBottomSheet( - share = it, - internalLink = internalLink, - viewModel = viewModel, - initialPresetOption = editorInitialPreset, - onDismissDraft = { draftShare -> - activeShare.let { viewModel.deleteShare(draftShare.id) } - viewModel.setActiveShare(null) - } - ) - } + activeShare.shareOrNull?.let { activeShareObject -> + AddOrEditShareBottomSheet( + share = activeShareObject, + internalLink = internalLink, + viewModel = viewModel, + initialPresetOption = editorInitialPreset, + onDismissDraft = { draftShare -> + viewModel.deleteShare(draftShare.id) + viewModel.setActiveShare(null) + } + ) } } @@ -252,7 +245,7 @@ private fun ShareItem( onLongClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) overlayState = ShareItemOverlayState.ContextMenu - }, + } ) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), headlineContent = { diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt index 299e05ae..a32d7bc9 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -56,7 +56,7 @@ class ShareViewModel( private val _state = MutableStateFlow(ShareScreenState.Loading) val state: StateFlow = _state - private val _activeShare = MutableStateFlow(ActiveShareState.Dismiss) + private val _activeShare = MutableStateFlow(ActiveShareState.None) val activeShare: StateFlow = _activeShare private val _searchQuery = MutableStateFlow("") @@ -165,9 +165,9 @@ class ShareViewModel( ?: return@launch if (shareState == ShareState.ACTIVE) { if (updateAndDontDismiss) { - _activeShare.update { ActiveShareState.Update(updated) } + _activeShare.update { ActiveShareState.Activating(updated) } } else { - _activeShare.update { ActiveShareState.Dismiss } + _activeShare.update { ActiveShareState.None } } } else { _activeShare.update { updated.toActiveShare() } @@ -332,11 +332,9 @@ class ShareViewModel( else ShareScreenState.Loaded(remaining) } - if (_activeShare.value is ActiveShareState.SharedAndDismiss) { - val sharedAndDismiss = (_activeShare.value as ActiveShareState.SharedAndDismiss) - if (sharedAndDismiss.value.id == id) { - _activeShare.update { ActiveShareState.Dismiss } - } + val editingShare = _activeShare.value + if (editingShare is ActiveShareState.Editing && editingShare.share.id == id) { + _activeShare.update { ActiveShareState.None } } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt index f44a2d10..c9388669 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -48,7 +48,7 @@ data class Share( val permissionPreset: PermissionPreset? = null ) { fun toActiveShare(): ActiveShareState { - return ActiveShareState.SharedAndDismiss(this) + return ActiveShareState.Editing(this) } fun getClipEntry(internalLink: String, category: ShareCategory): ClipEntry? { diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ActiveShareState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ActiveShareState.kt index 9c6f6884..e7b56192 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ActiveShareState.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ActiveShareState.kt @@ -10,7 +10,14 @@ package com.nextcloud.android.common.ui.share.model.ui import com.nextcloud.android.common.ui.share.model.api.share.Share sealed class ActiveShareState { - data object Dismiss: ActiveShareState() - data class Update(val value: Share): ActiveShareState() - data class SharedAndDismiss(val value: Share): ActiveShareState() + data object None : ActiveShareState() + data class Editing(val share: Share) : ActiveShareState() + data class Activating(val share: Share) : ActiveShareState() + + val shareOrNull: Share? + get() = when (this) { + is Editing -> share + is Activating -> share + None -> null + } }