From 53a01fc8805e185941e0554428bb904b1c1bdda4 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 20 Apr 2026 16:00:01 +0200 Subject: [PATCH 001/144] 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 9d159708..d06344d7 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -335,6 +335,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 1d043c8d315a60b4ddbc0b82b3c643ecc7f3e41c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 09:52:07 +0200 Subject: [PATCH 002/144] 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 03cab7842c9159d633c507b99a1f294b92512e27 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 10:08:58 +0200 Subject: [PATCH 003/144] 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 f0c3122b5419379e9bfb14b27ef48a442c5c37db Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 12:41:47 +0200 Subject: [PATCH 004/144] 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 c5aaf02d684fa24691694b519eda236fd5cb353d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 12:44:48 +0200 Subject: [PATCH 005/144] 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 3a007595d2087250f5d296168580a86db6b11bbf Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 15:25:47 +0200 Subject: [PATCH 006/144] 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 028e211bd8abd28f944963e1e4accd4e1a4cc912 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 14:02:16 +0200 Subject: [PATCH 007/144] 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 0690eedd23f3857c79446f0ab34585e125ad8542 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Apr 2026 10:39:10 +0200 Subject: [PATCH 008/144] 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 0f1b8d398f1e8a454b99345ce24b748121f99cee Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 14:11:09 +0200 Subject: [PATCH 009/144] 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 2fe8f3ec25e4b912a8e0dbc27a777d987f981755 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 14:56:55 +0200 Subject: [PATCH 010/144] 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 3f7db02214e3cf058e751fbf8e2b3c62161f6cd3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 16:16:59 +0200 Subject: [PATCH 011/144] 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 ebe6400462841835db847fdfbaabc9f2b5aaad63 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 16:20:32 +0200 Subject: [PATCH 012/144] 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 0c70b78f2398043a11e257a253f4e070c605f886 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 16:21:37 +0200 Subject: [PATCH 013/144] 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 e474ca6c48112221453f811ad9c8c9944b43477d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 6 May 2026 08:38:51 +0200 Subject: [PATCH 014/144] 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 18c599b761f57183db81cf365749fea38213012a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 6 May 2026 08:56:45 +0200 Subject: [PATCH 015/144] 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 504999ad9154a3c49292e62f7b3562484563ed54 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 12:18:47 +0300 Subject: [PATCH 016/144] 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 e7ce07962f1f998a637c589cb912fbefe6315d52 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 15:54:22 +0300 Subject: [PATCH 017/144] 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 784803d43495a6cc52e1e1012f4247bc1da78512 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 16:16:01 +0300 Subject: [PATCH 018/144] 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 f621f1ada7141a7f163a84c5582b5770fd851b30 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 16:49:33 +0300 Subject: [PATCH 019/144] 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 6fb9a6c206e87c639beb02d793579f1c358faef8 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 17:02:32 +0300 Subject: [PATCH 020/144] 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 ff44f4b5492a9c0ac7df2e330d2c24449f34982c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 28 May 2026 15:04:34 +0300 Subject: [PATCH 021/144] 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 e7ff879321000bf5228cbf7416ced6d0c6f8c4ed Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 28 May 2026 15:48:42 +0300 Subject: [PATCH 022/144] 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 3cd34bcd5408cd8c5ee7297a56730a2f21d3909d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 28 May 2026 16:02:29 +0300 Subject: [PATCH 023/144] 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 bb9738e0ce40d4fafa8e45064255b690b19c54bb Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 12:40:08 +0300 Subject: [PATCH 024/144] 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 ab64d6946554358017c54193f3ac92f02a90f1e2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 12:46:17 +0300 Subject: [PATCH 025/144] 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 8bd7b139e2429e563084c18583d7fae578e29087 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 13:40:44 +0300 Subject: [PATCH 026/144] 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 e34fcd9156def6535311da53ed243dde21f47b8f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 15:27:41 +0300 Subject: [PATCH 027/144] 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 ede7239d4f8432a2ed50e4e1a8a7db9916da61f6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 15:43:15 +0300 Subject: [PATCH 028/144] 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 522f51a727bf330ca34273d411339c7d87a2ce88 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 16:12:05 +0300 Subject: [PATCH 029/144] 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 4adb97582c7dd41adbc0fa543aaaa354026e0bb3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 16:12:30 +0300 Subject: [PATCH 030/144] 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 1e96e8759d5211ca4f5bf3082e2a099a53a6695d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 08:48:43 +0300 Subject: [PATCH 031/144] 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 6cabb9b0a1ba558a445e37a32badb941c351a776 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 08:49:41 +0300 Subject: [PATCH 032/144] 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 7189593987d1c3e3595e17b01199c4be5f08728a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 08:59:44 +0300 Subject: [PATCH 033/144] 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 7d46cc6921de2e0a12fd1ad96ca02d487f4e0090 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 09:11:06 +0300 Subject: [PATCH 034/144] 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 31e551cbe9be4971341edc5f9963c84cd3b30b65 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 09:16:03 +0300 Subject: [PATCH 035/144] 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 87c3319d5c7032e0ed44250f06d2e592ee7e758d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 09:52:27 +0300 Subject: [PATCH 036/144] 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 c9eeb6f4c06467c53c2dd6c5a36ef0d48be52ea2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 10:10:20 +0300 Subject: [PATCH 037/144] 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 78e54cce947a154107370315aa765fabf234ac47 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 10:22:06 +0300 Subject: [PATCH 038/144] 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 cb66d5e826a5db3aa877eb40ec958701a9a9ca9d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 10:29:27 +0300 Subject: [PATCH 039/144] 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 15b33942e968ff8ac904fcb7300e966d7ed45c93 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 10:30:12 +0300 Subject: [PATCH 040/144] 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 899bc22fbd81bf83b83db23f39daa13d36a7bb95 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 9 Jun 2026 11:31:18 +0200 Subject: [PATCH 041/144] 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 b0e475445cb41647bab3b431480de7af1a74369a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 9 Jun 2026 11:31:37 +0200 Subject: [PATCH 042/144] 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 d06344d7..922c0cfa 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -77,6 +77,7 @@ + @@ -105,6 +106,11 @@ + + + + + @@ -174,6 +180,7 @@ + @@ -281,6 +288,7 @@ + @@ -296,6 +304,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 05e16361e4a691c95037902b01cbaba0a792034b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 9 Jun 2026 12:03:53 +0200 Subject: [PATCH 043/144] 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 d5ac881b3ff4bbb29f265be11aa3da5ff711f6f3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 9 Jun 2026 12:14:28 +0200 Subject: [PATCH 044/144] 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 d79e4a3818cd7654201554aa60feccbe13403832 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 9 Jun 2026 15:56:33 +0200 Subject: [PATCH 045/144] 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 c00ec4f3c116ab9f16ed1b9ae659cc4d5c2f176e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 12:31:06 +0200 Subject: [PATCH 046/144] 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 1b4aebde3a99bf7236af350790968a18d2018f09 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 12:31:12 +0200 Subject: [PATCH 047/144] 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 d500a2db459048e26c2ed25a648201f763c7599e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 14:07:09 +0200 Subject: [PATCH 048/144] 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 5099fe6b0fd1f2150bb254f163b4aac9fb08e02d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 14:12:46 +0200 Subject: [PATCH 049/144] 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 cf3aab5d482b0500b91ca8d2125884324fd8bff3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 14:14:25 +0200 Subject: [PATCH 050/144] 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 a26ec5c69ee7060f2783ea8b71d4915817adfe91 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 14:38:44 +0200 Subject: [PATCH 051/144] 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 0d910137f6265763c30de5057421eab97bc96fff Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 15:09:25 +0200 Subject: [PATCH 052/144] 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 56be34f723794ae795f47b47395629bf0b9b538b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 15:34:28 +0200 Subject: [PATCH 053/144] 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 066359a0ee2db9d8918be54ab040f4f8ccffc188 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 15:47:01 +0200 Subject: [PATCH 054/144] 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 e3308b9d07a7d4ab0498982b3eed72104ac4dfb2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 15:59:00 +0200 Subject: [PATCH 055/144] 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 4e9920e2d5f1bb6d67e2991cc960941e788a1803 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 16:11:43 +0200 Subject: [PATCH 056/144] 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 ef158908eb2c6a81b5afaa96d334f08d94cc9408 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 16:47:17 +0200 Subject: [PATCH 057/144] 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 ccaab9637989d906f980a52b8893f57090f873fe Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 10 Jun 2026 16:52:21 +0200 Subject: [PATCH 058/144] 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 8dcd8f85bc96a4ba9d1f367bebcf152f880ab2f3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 22 Jun 2026 11:55:00 +0200 Subject: [PATCH 059/144] 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 fe6ed09a2c34ab1bcbf4868c4d5f9572fbdff472 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 13:51:52 +0200 Subject: [PATCH 060/144] wip Signed-off-by: alperozturk96 # Conflicts: # gradle/verification-metadata.xml --- sample/build.gradle | 1 + ui/build.gradle | 18 +++++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) 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 597c21f1de96cbd5aa437be933e2c7e66c004bd2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 15:00:14 +0200 Subject: [PATCH 061/144] 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 b40b147025c1fe1341d76ab5e92b3df9bb01b258 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 15:00:22 +0200 Subject: [PATCH 062/144] 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 6f78645d174bf58b61dedacd6b1390f1a33521cf Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 15:14:09 +0200 Subject: [PATCH 063/144] 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 0a6e8f92ef457a1069267e3f67d1409e70e71524 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 15:22:21 +0200 Subject: [PATCH 064/144] 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 dbe4728fd86de08f95b09bfd5b48eeb7b9673e2b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 15:35:11 +0200 Subject: [PATCH 065/144] 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 59b4e3cdb59bd980caa9cd6389a6e432d01fa19f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 15:53:07 +0200 Subject: [PATCH 066/144] 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 74f67ca2b4df98edfee44f3bc26a5ce55ca53f7b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 16:03:28 +0200 Subject: [PATCH 067/144] 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 2db56f1620f884cf75277982c85a2c1c8ec98f13 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 2 Jul 2026 16:04:46 +0200 Subject: [PATCH 068/144] 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 2b7375e5e568a7fbd29be231fe41c2203646fa65 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 08:11:50 +0200 Subject: [PATCH 069/144] 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 96dfac8a2da233a52b058157f08c8ebe5b9eebbe Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 08:21:17 +0200 Subject: [PATCH 070/144] 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 e80c549c9e0d45de9fbc7aa442df493138d3c43d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 08:27:45 +0200 Subject: [PATCH 071/144] 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 76b3b8be678ad5aa136ef235d765a8dd46aeb508 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 08:34:37 +0200 Subject: [PATCH 072/144] 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 e65fc5e9242fa9190dcb74082741ef0ab7539b8e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 08:36:28 +0200 Subject: [PATCH 073/144] 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 80831263616e9e5e9e28e1611657bbcd9ddead9e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 09:06:38 +0200 Subject: [PATCH 074/144] 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 e28f64ba983771b8b996a5d13ecba712e86317da Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 09:25:02 +0200 Subject: [PATCH 075/144] 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 30c6836baf0fb7e7593f40189359371fa43ef929 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 09:37:58 +0200 Subject: [PATCH 076/144] 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 8791cb76405446db8c6e3e140a9ce41cc2ed840f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 12:24:44 +0200 Subject: [PATCH 077/144] 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 4d3a701aa72cf604e61f17a0eb7aa59eb9b42692 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 12:43:33 +0200 Subject: [PATCH 078/144] 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 ffa52cb30b6e0073fded014aaa02bf2e5ebcf85c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 12:44:12 +0200 Subject: [PATCH 079/144] 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 90f4f542f132d888c5281a253479745e4c9baf3c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 12:51:00 +0200 Subject: [PATCH 080/144] 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 6ee7032b17fe60fc2d5be77af33f440100194c85 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 12:55:32 +0200 Subject: [PATCH 081/144] 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 34d538d634c5a8645e2f38a94f727cb8fdecb9b3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 13:12:00 +0200 Subject: [PATCH 082/144] 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 29f812fe6e32bff9cd76b8dbfb61124a12161900 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 3 Jul 2026 13:58:03 +0200 Subject: [PATCH 083/144] 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 9e59cf922a32398e0e6f106d7e3ef3671704d89d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 13:34:10 +0200 Subject: [PATCH 084/144] 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 6f186a13cce79012a81f93c8003ba1e6362ed431 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 13:51:05 +0200 Subject: [PATCH 085/144] 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 1312b443ac8aa56e745b2ab5f403f864f6ead7a1 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 14:01:55 +0200 Subject: [PATCH 086/144] 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 dd14e1b5f52a9132b2d9d95dbdb05945472d7488 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 14:09:36 +0200 Subject: [PATCH 087/144] 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 5bd1defe023dc07c80ffc454d7e5796ce3bd1672 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 14:21:23 +0200 Subject: [PATCH 088/144] 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 74156b12ddd0a7a4ef0201652ac46769ad847674 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 14:52:44 +0200 Subject: [PATCH 089/144] 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 da0df8f6d29911737dee7859562c1715a7fdf925 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 15:42:25 +0200 Subject: [PATCH 090/144] 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 e002b5518a421027e0e46c7c97f29c99c245a6e5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 6 Jul 2026 16:01:32 +0200 Subject: [PATCH 091/144] 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 b40c5276f4b9ce5221e09c52afdc2e7b3bde1c4d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 8 Jul 2026 15:10:48 +0200 Subject: [PATCH 092/144] 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 e444b50d5e4b04b221c633cf6b30d52005c1387e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 8 Jul 2026 15:16:36 +0200 Subject: [PATCH 093/144] 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 f6b87a2255f45bfafc8107dc3746df6cf76bf7a7 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 8 Jul 2026 15:40:12 +0200 Subject: [PATCH 094/144] 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 8f06bf6ba3b9e4c282a11c51dada1dd012457d48 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 8 Jul 2026 15:49:28 +0200 Subject: [PATCH 095/144] 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 + } } From 930670293261e17018c4e0e67d2aa5532f8e68da Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 9 Jul 2026 11:34:03 +0200 Subject: [PATCH 096/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 18 ++++++++++-------- .../share/component/SelectRecipientField.kt | 13 +++++++++++++ .../common/ui/share/model/api/share/Share.kt | 19 +++++++++++++++++++ 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_talk.xml | 18 ------------------ ui/src/main/res/drawable/ic_user.xml | 16 ---------------- ui/src/main/res/values/strings.xml | 2 ++ 9 files changed, 44 insertions(+), 90 deletions(-) delete mode 100644 ui/src/main/res/drawable/ic_circles.xml delete mode 100644 ui/src/main/res/drawable/ic_email.xml delete mode 100644 ui/src/main/res/drawable/ic_group.xml delete mode 100644 ui/src/main/res/drawable/ic_talk.xml delete mode 100644 ui/src/main/res/drawable/ic_user.xml 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 f5411ae4..288e85d4 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 @@ -58,6 +58,7 @@ 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 @@ -70,6 +71,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.PublicLinkIcon 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 @@ -203,6 +205,7 @@ private fun ShareItem( onDeleteShare: (Share) -> Unit, onSendEmail: (Share) -> Unit ) { + val context = LocalContext.current var overlayState by remember { mutableStateOf(ShareItemOverlayState.None) } val haptics = LocalHapticFeedback.current @@ -249,19 +252,18 @@ private fun ShareItem( ) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), headlineContent = { - val headline = if (share.recipients.isNotEmpty()) { - share.recipients.first().displayName - } else { - "" - } - Text( - text = headline, + text = share.getHeadline(context), style = MaterialTheme.typography.titleMedium ) }, leadingContent = { - share.recipients.first().icon?.let { RecipientIcon(icon = it) } + val icon = share.recipients.first().icon + if (icon != null) { + RecipientIcon(icon = icon) + } else { + PublicLinkIcon() + } }, supportingContent = { val chipHorizontalPadding = 10.dp diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt index 82bf6618..1a9bbff3 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt @@ -7,6 +7,7 @@ package com.nextcloud.android.common.ui.share.component +import android.graphics.Color import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -30,6 +31,7 @@ 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.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage @@ -172,6 +174,17 @@ private fun RecipientDropdownContent( } } +// TODO: can this come from backend? +@Composable +fun PublicLinkIcon(modifier: Modifier = Modifier) { + Icon( + painter = painterResource(R.drawable.ic_link), + contentDescription = "public link icon", + tint = MaterialTheme.colorScheme.onSurface, + modifier = modifier + ) +} + @Composable fun RecipientIcon(icon: Icon, modifier: Modifier = Modifier) { val isDark = isSystemInDarkTheme() 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 c9388669..3dbb8311 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 @@ -62,6 +62,25 @@ data class Share( } } + fun getHeadline(context: Context): String { + return if (belongsAnyoneTab) { + // TODO: maybe backend can provide this + // FIXME: Share link 1,2,3 show this in UI + context.getString(R.string.share_view_public_link) + } else { + if (recipients.isNotEmpty()) { + recipients.first().displayName + } else { + "" + } + } + } + + val belongsAnyoneTab: Boolean + get() { + return customLinkRecipient != null + } + val customLinkRecipient: Recipient? get() { return recipients.firstOrNull { it.secret.updatable } diff --git a/ui/src/main/res/drawable/ic_circles.xml b/ui/src/main/res/drawable/ic_circles.xml deleted file mode 100644 index 5b07aff7..00000000 --- a/ui/src/main/res/drawable/ic_circles.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/ui/src/main/res/drawable/ic_email.xml b/ui/src/main/res/drawable/ic_email.xml deleted file mode 100644 index 3319f67e..00000000 --- a/ui/src/main/res/drawable/ic_email.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/ui/src/main/res/drawable/ic_group.xml b/ui/src/main/res/drawable/ic_group.xml deleted file mode 100644 index e68f08e7..00000000 --- a/ui/src/main/res/drawable/ic_group.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/ui/src/main/res/drawable/ic_talk.xml b/ui/src/main/res/drawable/ic_talk.xml deleted file mode 100644 index e55ac5d9..00000000 --- a/ui/src/main/res/drawable/ic_talk.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - diff --git a/ui/src/main/res/drawable/ic_user.xml b/ui/src/main/res/drawable/ic_user.xml deleted file mode 100644 index d6267b2f..00000000 --- a/ui/src/main/res/drawable/ic_user.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 063b4096..401205c8 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -9,6 +9,8 @@ OK Cancel + Share link + Create a new share Share %s From 5ef1bbe192368fe0db3dedd6a002cfbf14701b02 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 9 Jul 2026 11:39:49 +0200 Subject: [PATCH 097/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 22 ++++++++++++++----- .../share/component/SelectRecipientField.kt | 4 ++++ ui/src/main/res/values/strings.xml | 2 +- 3 files changed, 21 insertions(+), 7 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 288e85d4..2788217d 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 @@ -63,6 +63,7 @@ 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.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory @@ -254,15 +255,22 @@ private fun ShareItem( headlineContent = { Text( text = share.getHeadline(context), - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) }, leadingContent = { - val icon = share.recipients.first().icon - if (icon != null) { - RecipientIcon(icon = icon) - } else { - PublicLinkIcon() + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center + ) { + val icon = share.recipients.firstOrNull()?.icon + if (icon != null) { + RecipientIcon(icon = icon, modifier = Modifier.size(24.dp)) + } else { + PublicLinkIcon(modifier = Modifier.size(24.dp)) + } } }, supportingContent = { @@ -280,6 +288,8 @@ private fun ShareItem( text = stringResource(PermissionPresetOption.from(share.permissionPreset).labelRes), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.width(2.dp)) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt index 1a9bbff3..bbb8ac83 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt @@ -9,6 +9,7 @@ package com.nextcloud.android.common.ui.share.component import android.graphics.Color import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -199,5 +200,8 @@ fun RecipientIcon(icon: Icon, modifier: Modifier = Modifier) { contentDescription = null, modifier = modifier, ) + } else { + // reserve the same space even with no image + Box(modifier = modifier) } } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 401205c8..e44220bb 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -44,7 +44,7 @@ Failed to update share. Some settings couldn\'t be saved. Fix the highlighted fields before sharing. - Set permission + Change permission Participants Can… Can view From 962547e19ca5972cc6b82874cab50be137eed278 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 9 Jul 2026 11:47:13 +0200 Subject: [PATCH 098/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 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 2788217d..3d680b58 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 @@ -54,6 +54,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -63,6 +64,7 @@ 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.semantics.clearAndSetSemantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.ImageLoader @@ -275,6 +277,8 @@ private fun ShareItem( }, supportingContent = { val chipHorizontalPadding = 10.dp + val selectedLabelRes = PermissionPresetOption.from(share.permissionPreset).labelRes + Row( modifier = Modifier .offset(x = -chipHorizontalPadding) @@ -284,13 +288,25 @@ private fun ShareItem( .padding(horizontal = chipHorizontalPadding, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = stringResource(PermissionPresetOption.from(share.permissionPreset).labelRes), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Box(contentAlignment = Alignment.CenterStart) { + PermissionPresetOption.entries.forEach { option -> + Text( + text = stringResource(option.labelRes), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + modifier = Modifier + .alpha(0f) + .clearAndSetSemantics {} + ) + } + + Text( + text = stringResource(selectedLabelRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1 + ) + } Spacer(modifier = Modifier.width(2.dp)) From f065b00b5e7b871fd42fb78e75bf0132cfe07678 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 9 Jul 2026 12:07:35 +0200 Subject: [PATCH 099/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 5 +- .../bottomsheet/AddOrEditShareBottomSheet.kt | 55 +++++++++++++------ 2 files changed, 39 insertions(+), 21 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 a32d7bc9..610f4b61 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 @@ -347,10 +347,7 @@ class ShareViewModel( fun setActiveShare(value: Share?) { _propertyErrors.update { emptyMap() } - - value?.let { - _activeShare.update { value.toActiveShare() } - } + _activeShare.update { value?.toActiveShare() ?: ActiveShareState.None } } // 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 e86484a5..55911083 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 @@ -81,7 +81,9 @@ fun AddOrEditShareBottomSheet( ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val categories = remember { ShareCategory.entries.toList() } - var selectedCategory by remember { mutableStateOf(categories.first()) } + var selectedCategory by remember(share.id) { + mutableStateOf(if (share.belongsAnyoneTab) ShareCategory.Anyone else ShareCategory.Invited) + } var showAdvancedSettings by remember { mutableStateOf(false) } val context = LocalContext.current val propertyErrors by viewModel.propertyErrors.collectAsState() @@ -114,23 +116,15 @@ fun AddOrEditShareBottomSheet( modifier = Modifier.padding(16.dp) ) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(8.dp)) { - categories.forEachIndexed { index, category -> - SegmentedButton( - selected = selectedCategory == category, - onClick = { - selectedCategory = category - viewModel.selectCategory(category, share) - }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size), - icon = { - Icon(painter = painterResource(category.iconId), contentDescription = "") - } - ) { - Text(stringResource(category.titleId)) - } + ShareCategorySelector( + share = share, + categories = categories, + selectedCategory = selectedCategory, + onCategorySelected = { category -> + selectedCategory = category + viewModel.selectCategory(category, share) } - } + ) if (selectedCategory == ShareCategory.Invited) { SelectRecipientField(share, viewModel) @@ -176,6 +170,33 @@ fun AddOrEditShareBottomSheet( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShareCategorySelector( + share: Share, + categories: List, + selectedCategory: ShareCategory, + onCategorySelected: (ShareCategory) -> Unit +) { + // only allow user to select between taps if it is draft share + if (share.shareState != ShareState.DRAFT) return + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + 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 PermissionsView( From 55f4159948376cf8c13e70fd61105b7235424fbb Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 9 Jul 2026 14:47:38 +0200 Subject: [PATCH 100/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 12 ++++++++---- .../common/ui/share/model/api/share/Share.kt | 18 +++++++++--------- ui/src/main/res/values/strings.xml | 1 + 3 files changed, 18 insertions(+), 13 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 3d680b58..ded8c048 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 @@ -98,6 +98,7 @@ private fun ShareScreen(sourceId: String, internalLink: String, viewModel: Share val snackbarHostState = remember { SnackbarHostState() } val resources = LocalResources.current var editorInitialPreset by remember { mutableStateOf(null) } + val context = LocalContext.current LaunchedEffect(errorMessageId) { errorMessageId?.let { @@ -149,8 +150,10 @@ private fun ShareScreen(sourceId: String, internalLink: String, viewModel: Share .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - itemsIndexed(state.shares.filtered(), key = { _, share -> share.id }) { index, share -> - val type = ShareItemType.type(index, state.shares.lastIndex) + val shares = state.shares.filtered() + itemsIndexed(shares, key = { _, share -> share.id }) { index, share -> + val type = ShareItemType.type(index, shares.lastIndex) + val title = share.getHeadline(context, shares) if (index == 0) { Spacer(modifier = Modifier.height(16.dp)) @@ -160,6 +163,7 @@ private fun ShareScreen(sourceId: String, internalLink: String, viewModel: Share ShareItem( share = share, + title = title, type = type, onSelectShare = { selected -> editorInitialPreset = null @@ -201,6 +205,7 @@ private fun ShareScreen(sourceId: String, internalLink: String, viewModel: Share @Composable private fun ShareItem( share: Share, + title: String, type: ShareItemType, onSelectShare: (Share) -> Unit, onCustomizeShare: (Share) -> Unit, @@ -208,7 +213,6 @@ private fun ShareItem( onDeleteShare: (Share) -> Unit, onSendEmail: (Share) -> Unit ) { - val context = LocalContext.current var overlayState by remember { mutableStateOf(ShareItemOverlayState.None) } val haptics = LocalHapticFeedback.current @@ -256,7 +260,7 @@ private fun ShareItem( .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), headlineContent = { Text( - text = share.getHeadline(context), + text = title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis 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 3dbb8311..b28f82b6 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 @@ -62,17 +62,17 @@ data class Share( } } - fun getHeadline(context: Context): String { - return if (belongsAnyoneTab) { - // TODO: maybe backend can provide this - // FIXME: Share link 1,2,3 show this in UI + fun getHeadline(context: Context, shares: List): String { + if (!belongsAnyoneTab) { + return recipients.firstOrNull()?.displayName ?: "" + } + + val publicLinks = shares.filter { it.belongsAnyoneTab } + return if (publicLinks.size <= 1) { context.getString(R.string.share_view_public_link) } else { - if (recipients.isNotEmpty()) { - recipients.first().displayName - } else { - "" - } + val position = publicLinks.sortedBy { it.lastUpdated }.indexOfFirst { it.id == id } + 1 + context.getString(R.string.share_view_public_link_numbered, position) } } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index e44220bb..faa52182 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ Cancel Share link + Share link (%1$d) Create a new share Share %s From e8dd46b7368100da2b93456730a9a01b0d1fc16d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 9 Jul 2026 15:14:41 +0200 Subject: [PATCH 101/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 11 ++++++----- .../bottomsheet/AddOrEditShareBottomSheet.kt | 9 +++++++-- .../common/ui/share/model/ui/ShareEditorEntry.kt | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareEditorEntry.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 ded8c048..68a241fb 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 @@ -82,6 +82,7 @@ import com.nextcloud.android.common.ui.share.component.dialog.DeleteShareConfirm 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.ShareEditorEntry 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 @@ -97,7 +98,7 @@ private fun ShareScreen(sourceId: String, internalLink: String, viewModel: Share val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } val resources = LocalResources.current - var editorInitialPreset by remember { mutableStateOf(null) } + var editorEntry by remember { mutableStateOf(ShareEditorEntry.EDIT) } val context = LocalContext.current LaunchedEffect(errorMessageId) { @@ -166,11 +167,11 @@ private fun ShareScreen(sourceId: String, internalLink: String, viewModel: Share title = title, type = type, onSelectShare = { selected -> - editorInitialPreset = null + editorEntry = ShareEditorEntry.EDIT viewModel.setActiveShare(selected) }, onCustomizeShare = { selected -> - editorInitialPreset = PermissionPresetOption.CUSTOM + editorEntry = ShareEditorEntry.CUSTOMIZE_PERMISSION viewModel.setActiveShare(selected) }, onChangePreset = { selected, preset -> @@ -178,7 +179,7 @@ private fun ShareScreen(sourceId: String, internalLink: String, viewModel: Share }, onDeleteShare = { viewModel.deleteShare(it.id) }, onSendEmail = { selected -> - editorInitialPreset = null + editorEntry = ShareEditorEntry.SEND_EMAIL viewModel.setActiveShare(selected) } ) @@ -193,7 +194,7 @@ private fun ShareScreen(sourceId: String, internalLink: String, viewModel: Share share = activeShareObject, internalLink = internalLink, viewModel = viewModel, - initialPresetOption = editorInitialPreset, + entry = editorEntry, onDismissDraft = { draftShare -> viewModel.deleteShare(draftShare.id) viewModel.setActiveShare(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 55911083..a72d4975 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 @@ -67,6 +67,7 @@ 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.model.ui.ShareEditorEntry import com.nextcloud.android.common.ui.share.repository.MockShareRepository import kotlinx.coroutines.launch @@ -76,7 +77,7 @@ fun AddOrEditShareBottomSheet( share: Share, internalLink: String, viewModel: ShareViewModel, - initialPresetOption: PermissionPresetOption? = null, + entry: ShareEditorEntry = ShareEditorEntry.EDIT, onDismissDraft: (Share) -> Unit = {} ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -84,7 +85,11 @@ fun AddOrEditShareBottomSheet( var selectedCategory by remember(share.id) { mutableStateOf(if (share.belongsAnyoneTab) ShareCategory.Anyone else ShareCategory.Invited) } - var showAdvancedSettings by remember { mutableStateOf(false) } + val initialPresetOption = when (entry) { + ShareEditorEntry.CUSTOMIZE_PERMISSION -> PermissionPresetOption.CUSTOM + else -> null + } + var showAdvancedSettings by remember(share.id) { mutableStateOf(entry == ShareEditorEntry.SEND_EMAIL) } val context = LocalContext.current val propertyErrors by viewModel.propertyErrors.collectAsState() val hasPropertyErrors = propertyErrors.values.any { it != null } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareEditorEntry.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareEditorEntry.kt new file mode 100644 index 00000000..b4208f34 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareEditorEntry.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 + +enum class ShareEditorEntry { + EDIT, + CUSTOMIZE_PERMISSION, + SEND_EMAIL +} From 6113c44ae548a44b5593f6e52c137f5f1a935457 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 13 Jul 2026 10:36:53 +0200 Subject: [PATCH 102/144] wip Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 58 +++++++++++++++---- .../property/datepicker/ShareDatePicker.kt | 1 + .../share/model/api/permission/Permission.kt | 2 + .../ui/share/model/api/property/Property.kt | 6 ++ .../common/ui/share/model/api/share/Share.kt | 16 +++++ .../share/repository/MockShareRepository.kt | 7 +++ 6 files changed, 80 insertions(+), 10 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 a72d4975..f45fb550 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,14 +141,17 @@ fun AddOrEditShareBottomSheet( viewModel = viewModel ) - if (share.properties.isNotEmpty() || share.customLinkRecipient != null) { - AdvancedSettingsSection( - share = share, - isExpanded = showAdvancedSettings, - onToggle = { showAdvancedSettings = !showAdvancedSettings }, - viewModel = viewModel - ) - } + BasicSettingsSection( + share = share, + viewModel = viewModel + ) + + AdvancedSettingsSection( + share = share, + isExpanded = showAdvancedSettings, + onToggle = { showAdvancedSettings = !showAdvancedSettings }, + viewModel = viewModel + ) if (hasPropertyErrors) { Text( @@ -279,6 +282,28 @@ private fun PermissionPresetDropdown( } } +@Composable +private fun BasicSettingsSection( + share: Share, + viewModel: ShareViewModel +) { + if (!share.isBasicSectionAvailable) { + return + } + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + share.basicProperties.forEach { property -> + key(property.clazz) { + SharePropertyView( + shareId = share.id, + property = property, + viewModel = viewModel + ) + } + } + } +} + @Composable private fun AdvancedSettingsSection( share: Share, @@ -286,12 +311,16 @@ private fun AdvancedSettingsSection( onToggle: () -> Unit, viewModel: ShareViewModel ) { + if (!share.isAdvancedSectionAvailable) { + return + } + CollapsibleShareSection( label = stringResource(R.string.share_view_advanced_settings), isExpanded = isExpanded, onToggle = onToggle ) { - share.properties.sortedBy { it.priority }.forEach { property -> + share.advancedProperties.forEach { property -> key(property.clazz) { SharePropertyView( shareId = share.id, @@ -450,18 +479,27 @@ private val previewShare = Share( ) ), properties = listOf( - PropertyString(clazz = "note", displayName = "Note", priority = 10, required = false, value = "") + PropertyString( + clazz = "note", + displayName = "Note", + priority = 10, + required = false, + advanced = false, + value = "" + ) ), permissions = listOf( Permission( clazz = "read", displayName = "Read", + priority = 10, presets = listOf(PermissionPreset.VIEW, PermissionPreset.EDIT), enabled = true ), Permission( clazz = "update", displayName = "Update", + priority = 20, presets = listOf(PermissionPreset.EDIT), enabled = false ) 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 0e8c76c9..6c8b6f51 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 @@ -115,6 +115,7 @@ private fun ShareDatePickerPreview() { displayName = "Expiration Date", priority = 1, required = false, + advanced = false, value = "2026-12-31", minDate = "2026-01-01", maxDate = "2027-12-31" 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 d568ae08..3dd22299 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,6 +23,8 @@ data class Permission( val hint: String? = null, + val priority: Int, + val presets: List, 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 index 27522128..8be8eaf1 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 @@ -21,6 +21,7 @@ sealed class Property { abstract val hint: String? abstract val priority: Int abstract val required: Boolean + abstract val advanced: Boolean abstract val value: String? } @@ -32,6 +33,7 @@ data class PropertyBoolean( override val hint: String? = null, override val priority: Int, override val required: Boolean, + override val advanced: Boolean, override val value: String? = null ) : Property() { fun isTrue(): Boolean = value == "true" @@ -45,6 +47,7 @@ data class PropertyDate( override val hint: String? = null, override val priority: Int, override val required: Boolean, + override val advanced: Boolean, override val value: String? = null, @SerialName("min_date") val minDate: String? = null, @SerialName("max_date") val maxDate: String? = null @@ -58,6 +61,7 @@ data class PropertyEnum( override val hint: String? = null, override val priority: Int, override val required: Boolean, + override val advanced: Boolean, override val value: String? = null, @SerialName("valid_values") val validValues: List ) : Property() @@ -70,6 +74,7 @@ data class PropertyPassword( override val hint: String? = null, override val priority: Int, override val required: Boolean, + override val advanced: Boolean, override val value: String? = null ) : Property() @@ -81,6 +86,7 @@ data class PropertyString( override val hint: String? = null, override val priority: Int, override val required: Boolean, + override val advanced: Boolean, override val value: String? = null, @SerialName("min_length") val minLength: Int? = null, @SerialName("max_length") val maxLength: Int? = null 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 b28f82b6..a2882efc 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 @@ -47,6 +47,22 @@ data class Share( @SerialName("permission_preset") val permissionPreset: PermissionPreset? = null ) { + val basicProperties: List + get() = properties.filterNot { it.advanced }.sortedBy { it.priority } + + val advancedProperties: List + get() = properties.filter { it.advanced }.sortedBy { it.priority } + + val isBasicSectionAvailable: Boolean + get() { + return basicProperties.isNotEmpty() + } + + val isAdvancedSectionAvailable: Boolean + get() { + return advancedProperties.isNotEmpty() || customLinkRecipient != null + } + fun toActiveShare(): ActiveShareState { return ActiveShareState.Editing(this) } 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 96c43283..ac024215 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 @@ -45,18 +45,21 @@ class MockShareRepository : ShareRepository { Permission( clazz = "read", displayName = "Read", + priority = 10, presets = listOf(PermissionPreset.VIEW, PermissionPreset.EDIT), enabled = true ), Permission( clazz = "update", displayName = "Update", + priority = 20, presets = listOf(PermissionPreset.EDIT), enabled = false ), Permission( clazz = "delete", displayName = "Delete", + priority = 30, presets = listOf(PermissionPreset.EDIT), enabled = false ) @@ -68,6 +71,7 @@ class MockShareRepository : ShareRepository { displayName = "Note", priority = 10, required = false, + advanced = false, value = "Design review – please check latest changes" ), PropertyPassword( @@ -75,6 +79,7 @@ class MockShareRepository : ShareRepository { displayName = "Password", priority = 20, required = false, + advanced = false, value = null ), PropertyDate( @@ -82,6 +87,7 @@ class MockShareRepository : ShareRepository { displayName = "Expiration date", priority = 30, required = false, + advanced = true, value = null, minDate = "2026-01-01", maxDate = "2027-01-01" @@ -91,6 +97,7 @@ class MockShareRepository : ShareRepository { displayName = "Hide download", priority = 40, required = false, + advanced = true, value = "false" ) ) From 7a2ed039d9c71cd39fa48084c790e952f3d8a249 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 13 Jul 2026 10:48:08 +0200 Subject: [PATCH 103/144] wip Signed-off-by: alperozturk96 --- .../ui/network/http/NextcloudHttpClient.kt | 33 +++++++++++-------- .../android/common/ui/share/ShareViewModel.kt | 5 +-- .../share/repository/MockShareRepository.kt | 11 +++++-- .../share/repository/ShareRemoteRepository.kt | 21 +++++++----- .../ui/share/repository/ShareRepository.kt | 5 +-- 5 files changed, 46 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 29b462e6..32d2dcc7 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,6 +10,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.Meta 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 @@ -31,6 +32,7 @@ class NextcloudHttpClient private constructor( private const val READ_TIMEOUT_SECONDS = 90L private const val WRITE_TIMEOUT_SECONDS = 90L private const val OCS_OK = "ok" + private const val OCS_FAILURE = "failure" fun create( credentials: ServerCredentials @@ -48,6 +50,8 @@ class NextcloudHttpClient private constructor( private val baseUrl get() = credentials.baseURL.trimEnd('/') + private fun errorMeta(code: Int): Meta = Meta(status = OCS_FAILURE, statusCode = code, message = "") + suspend fun executeRequest( endpoint: String, method: HttpMethod, @@ -59,24 +63,25 @@ class NextcloudHttpClient private constructor( val response = okHttpClient.newCall(request).execute() 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 isError = !response.isSuccessful + debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = isError) + return@withContext if (isError) { + NetworkResult.ServerError(OcsResponse(Ocs(errorMeta(response.code), ""))) + } else { + 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))) + val meta = OCSSerializer.json + .decodeFromString>(responseBody) + .ocs.meta + val isError = !response.isSuccessful || meta.status != OCS_OK + debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = isError) + + if (isError) { + NetworkResult.ServerError(OcsResponse(Ocs(meta, meta.message))) } else { - debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = false) NetworkResult.Success(parse(responseBody)) } } catch (e: Exception) { 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 610f4b61..87f6808f 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 @@ -108,7 +108,8 @@ class ShareViewModel( // region shares list fun fetchShares( - sourceClass: String? = null, + filterSourceTypeClass: String? = null, + filterSourceTypeValue: String? = null, lastShareID: String? = null, limit: Int = 50 ) { @@ -116,7 +117,7 @@ class ShareViewModel( _state.update { ShareScreenState.Loading } _errorMessageId.update { null } - val result = repository.fetchShares(sourceClass, lastShareID, limit) + val result = repository.fetchShares(filterSourceTypeClass, filterSourceTypeValue, lastShareID, limit) val fetched = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } ?: return@launch _state.update { 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 ac024215..4de05ce4 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 @@ -323,14 +323,19 @@ class MockShareRepository : ShareRepository { } override suspend fun fetchShares( - sourceClass: String?, + filterSourceTypeClass: String?, + filterSourceTypeValue: String?, lastShareID: String?, limit: Int ): NetworkResult> { var result = mockShares.toList() - if (sourceClass != null) { - result = result.filter { share -> share.sources.any { it.clazz == sourceClass } } + if (filterSourceTypeClass != null) { + result = result.filter { share -> + share.sources.any { + it.clazz == filterSourceTypeClass && (filterSourceTypeValue == null || it.value == filterSourceTypeValue) + } + } } if (lastShareID != null) { 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 71eccd8f..8a841e81 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 @@ -24,6 +24,9 @@ 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 okhttp3.RequestBody.Companion.toRequestBody +import java.net.URLEncoder + +private fun String.urlEncoded(): String = URLEncoder.encode(this, Charsets.UTF_8.name()) class ShareRemoteRepository( private val client: NextcloudHttpClient, @@ -44,8 +47,8 @@ class ShareRemoteRepository( offset: Int ): NetworkResult> { val queryParams = buildString { - append("?query=$query&limit=$limit&offset=$offset") - recipientTypeClasses?.forEach { append("&recipientTypeClasses[]=$it") } + append("?query=${query.urlEncoded()}&limit=$limit&offset=$offset") + recipientTypeClasses?.forEach { append("&recipientTypeClasses[]=${it.urlEncoded()}") } } return client.executeRequest( endpoint = "$RECIPIENTS_ENDPOINT$queryParams", @@ -83,14 +86,16 @@ class ShareRemoteRepository( ) { } override suspend fun fetchShares( - sourceClass: String?, + filterSourceTypeClass: String?, + filterSourceTypeValue: String?, lastShareID: String?, limit: Int ): NetworkResult> { val queryParams = buildString { append("?limit=$limit") - sourceClass?.let { append("&sourceClass=$it") } - lastShareID?.let { append("&lastShareID=$it") } + filterSourceTypeClass?.let { append("&filterSourceTypeClass=${it.urlEncoded()}") } + filterSourceTypeValue?.let { append("&filterSourceTypeValue=${it.urlEncoded()}") } + lastShareID?.let { append("&lastShareID=${it.urlEncoded()}") } } return client.executeRequest( endpoint = "$SHARES_ENDPOINT$queryParams", @@ -130,7 +135,7 @@ class ShareRemoteRepository( value: String ): NetworkResult = client.executeRequest( - endpoint = "$SHARE_ENDPOINT/$id/source?class=$clazz&value=$value", + endpoint = "$SHARE_ENDPOINT/$id/source?class=${clazz.urlEncoded()}&value=${value.urlEncoded()}", method = HttpMethod.DELETE ) { body -> json.decodeFromString>(body).ocs.data @@ -155,8 +160,8 @@ class ShareRemoteRepository( instance: String? ): NetworkResult { val queryParams = buildString { - append("?class=$clazz&value=$value") - instance?.let { append("&instance=$it") } + append("?class=${clazz.urlEncoded()}&value=${value.urlEncoded()}") + instance?.let { append("&instance=${it.urlEncoded()}") } } return client.executeRequest( endpoint = "$SHARE_ENDPOINT/$id/recipient$queryParams", 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 51bd49d2..b6c9d6a0 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 @@ -37,8 +37,9 @@ interface ShareRepository { suspend fun deleteShare(id: String): NetworkResult suspend fun fetchShares( - sourceClass: String?, - lastShareID: String?, + filterSourceTypeClass: String? = null, + filterSourceTypeValue: String? = null, + lastShareID: String? = null, limit: Int ): NetworkResult> From a433975dcc4086c38c5d3daf6c55e58c13b042c1 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 13 Jul 2026 10:54:14 +0200 Subject: [PATCH 104/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/network/http/NextcloudHttpClient.kt | 1 + 1 file changed, 1 insertion(+) 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 32d2dcc7..2cbfecc8 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 @@ -59,6 +59,7 @@ class NextcloudHttpClient private constructor( parse: (String) -> T ): NetworkResult = withContext(Dispatchers.IO) { val request = buildOcsRequest("$baseUrl$endpoint", method, body) + debugLogger.logRequest(endpoint, method, body) try { val response = okHttpClient.newCall(request).execute() val responseBody = response.body.string() From 7b2dcca1f5c53ff0b8cff8fad7c3c44c78d395de Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 14 Jul 2026 12:36:21 +0200 Subject: [PATCH 105/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 35 ++++++++++++------- .../android/common/ui/share/ShareViewModel.kt | 5 ++- .../bottomsheet/AddOrEditShareBottomSheet.kt | 26 ++++++++------ .../QuickSharePermissionBottomSheet.kt | 6 ++-- .../api/capabilities/SharingCapabilities.kt | 7 ++-- .../share/model/api/permission/Permission.kt | 5 ++- .../model/api/permission/PermissionPreset.kt | 14 ++++---- .../share/model/api/request/ShareRequests.kt | 3 +- .../common/ui/share/model/api/share/Share.kt | 3 +- .../share/model/ui/PermissionPresetOption.kt | 31 ++++++++++------ .../share/repository/MockShareRepository.kt | 9 +++-- ui/src/main/res/values/strings.xml | 2 -- 12 files changed, 89 insertions(+), 57 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 68a241fb..d18348bc 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 @@ -87,11 +87,17 @@ 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.model.ui.label import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository import kotlinx.coroutines.launch @Composable -private fun ShareScreen(sourceId: String, internalLink: String, viewModel: ShareViewModel) { +private fun ShareScreen( + sourceId: String, + internalLink: String, + viewModel: ShareViewModel, + permissionPresets: List +) { val errorMessageId by viewModel.errorMessageId.collectAsState() val screenState by viewModel.state.collectAsState() val activeShare by viewModel.activeShare.collectAsState() @@ -166,6 +172,7 @@ private fun ShareScreen(sourceId: String, internalLink: String, viewModel: Share share = share, title = title, type = type, + permissionPresets = permissionPresets, onSelectShare = { selected -> editorEntry = ShareEditorEntry.EDIT viewModel.setActiveShare(selected) @@ -194,6 +201,7 @@ private fun ShareScreen(sourceId: String, internalLink: String, viewModel: Share share = activeShareObject, internalLink = internalLink, viewModel = viewModel, + permissionPresets = permissionPresets, entry = editorEntry, onDismissDraft = { draftShare -> viewModel.deleteShare(draftShare.id) @@ -208,9 +216,10 @@ private fun ShareItem( share: Share, title: String, type: ShareItemType, + permissionPresets: List, onSelectShare: (Share) -> Unit, onCustomizeShare: (Share) -> Unit, - onChangePreset: (Share, PermissionPreset) -> Unit, + onChangePreset: (Share, String) -> Unit, onDeleteShare: (Share) -> Unit, onSendEmail: (Share) -> Unit ) { @@ -220,12 +229,13 @@ private fun ShareItem( when (overlayState) { is ShareItemOverlayState.QuickShare -> { QuickSharePermissionBottomSheet( - selectedOption = PermissionPresetOption.from(share.permissionPreset), + options = PermissionPresetOption.optionsFor(share, permissionPresets), + selectedOption = PermissionPresetOption.from(share.permissionPreset, permissionPresets), onOptionSelected = { option -> overlayState = ShareItemOverlayState.None - val preset = option.preset - if (preset != null) { - onChangePreset(share, preset) + val presetClass = option.presetClass + if (presetClass != null) { + onChangePreset(share, presetClass) } else { onCustomizeShare(share) } @@ -282,7 +292,7 @@ private fun ShareItem( }, supportingContent = { val chipHorizontalPadding = 10.dp - val selectedLabelRes = PermissionPresetOption.from(share.permissionPreset).labelRes + val selectedLabel = PermissionPresetOption.from(share.permissionPreset, permissionPresets).label() Row( modifier = Modifier @@ -294,9 +304,9 @@ private fun ShareItem( verticalAlignment = Alignment.CenterVertically ) { Box(contentAlignment = Alignment.CenterStart) { - PermissionPresetOption.entries.forEach { option -> + PermissionPresetOption.optionsFor(share, permissionPresets).forEach { option -> Text( - text = stringResource(option.labelRes), + text = option.label(), style = MaterialTheme.typography.bodyMedium, maxLines = 1, modifier = Modifier @@ -306,7 +316,7 @@ private fun ShareItem( } Text( - text = stringResource(selectedLabelRes), + text = selectedLabel, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, maxLines = 1 @@ -390,7 +400,8 @@ fun ComposeView.initShareScreen( sourceId: String, internalLink: String, credentials: ServerCredentials, - colorScheme: ColorScheme + colorScheme: ColorScheme, + permissionPresets: List = emptyList() ) { val nextcloudHttpClient = NextcloudHttpClient.create(credentials) val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)) @@ -405,7 +416,7 @@ fun ComposeView.initShareScreen( MaterialTheme( colorScheme = colorScheme, content = { - ShareScreen(sourceId, internalLink, viewModel) + ShareScreen(sourceId, internalLink, viewModel, permissionPresets) } ) } 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 87f6808f..234b74c2 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,7 +12,6 @@ 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 @@ -308,9 +307,9 @@ class ShareViewModel( } } - fun updatePermissionPreset(id: String, preset: PermissionPreset, updateActiveShare: Boolean) { + fun updatePermissionPreset(id: String, presetClass: String, updateActiveShare: Boolean) { viewModelScope.launch(Dispatchers.IO) { - val result = repository.updateSharePermissionPreset(id, UpdateSharePermissionPresetRequest(preset)) + val result = repository.updateSharePermissionPreset(id, UpdateSharePermissionPresetRequest(presetClass)) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch if (updateActiveShare) { 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 f45fb550..e6d25be6 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 @@ -68,6 +68,7 @@ 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.model.ui.ShareEditorEntry +import com.nextcloud.android.common.ui.share.model.ui.label import com.nextcloud.android.common.ui.share.repository.MockShareRepository import kotlinx.coroutines.launch @@ -77,6 +78,7 @@ fun AddOrEditShareBottomSheet( share: Share, internalLink: String, viewModel: ShareViewModel, + permissionPresets: List = emptyList(), entry: ShareEditorEntry = ShareEditorEntry.EDIT, onDismissDraft: (Share) -> Unit = {} ) { @@ -86,7 +88,7 @@ fun AddOrEditShareBottomSheet( mutableStateOf(if (share.belongsAnyoneTab) ShareCategory.Anyone else ShareCategory.Invited) } val initialPresetOption = when (entry) { - ShareEditorEntry.CUSTOMIZE_PERMISSION -> PermissionPresetOption.CUSTOM + ShareEditorEntry.CUSTOMIZE_PERMISSION -> PermissionPresetOption.Custom else -> null } var showAdvancedSettings by remember(share.id) { mutableStateOf(entry == ShareEditorEntry.SEND_EMAIL) } @@ -138,6 +140,7 @@ fun AddOrEditShareBottomSheet( PermissionsView( share = share, initialPresetOption = initialPresetOption, + permissionPresets = permissionPresets, viewModel = viewModel ) @@ -210,17 +213,19 @@ private fun ShareCategorySelector( private fun PermissionsView( share: Share, initialPresetOption: PermissionPresetOption?, + permissionPresets: List, viewModel: ShareViewModel ) { var selectedPreset by remember(share.id) { - mutableStateOf(if (initialPresetOption != null) initialPresetOption.preset else share.permissionPreset) + mutableStateOf(if (initialPresetOption != null) initialPresetOption.presetClass else share.permissionPreset) } PermissionPresetDropdown( - selectedOption = PermissionPresetOption.from(selectedPreset), + options = PermissionPresetOption.optionsFor(share, permissionPresets), + selectedOption = PermissionPresetOption.from(selectedPreset, permissionPresets), onOptionSelected = { option -> - selectedPreset = option.preset - option.preset?.let { viewModel.updatePermissionPreset(share.id, it, true) } + selectedPreset = option.presetClass + option.presetClass?.let { viewModel.updatePermissionPreset(share.id, it, true) } } ) @@ -244,6 +249,7 @@ private fun PermissionsView( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun PermissionPresetDropdown( + options: List, selectedOption: PermissionPresetOption, onOptionSelected: (PermissionPresetOption) -> Unit ) { @@ -255,7 +261,7 @@ private fun PermissionPresetDropdown( onExpandedChange = { expanded = it } ) { OutlinedTextField( - value = stringResource(selectedOption.labelRes), + value = selectedOption.label(), onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.share_view_permission_preset_label)) }, @@ -269,9 +275,9 @@ private fun PermissionPresetDropdown( expanded = expanded, onDismissRequest = { expanded = false } ) { - PermissionPresetOption.entries.forEach { option -> + options.forEach { option -> DropdownMenuItem( - text = { Text(stringResource(option.labelRes)) }, + text = { Text(option.label()) }, onClick = { expanded = false onOptionSelected(option) @@ -493,14 +499,14 @@ private val previewShare = Share( clazz = "read", displayName = "Read", priority = 10, - presets = listOf(PermissionPreset.VIEW, PermissionPreset.EDIT), + presets = listOf("view", "edit"), enabled = true ), Permission( clazz = "update", displayName = "Update", priority = 20, - presets = listOf(PermissionPreset.EDIT), + presets = listOf("edit"), enabled = false ) ), 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 index 0fdbbe75..399910d6 100644 --- 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 @@ -28,10 +28,12 @@ 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 +import com.nextcloud.android.common.ui.share.model.ui.label @OptIn(ExperimentalMaterial3Api::class) @Composable fun QuickSharePermissionBottomSheet( + options: List, selectedOption: PermissionPresetOption, onOptionSelected: (PermissionPresetOption) -> Unit, onDismiss: () -> Unit @@ -55,10 +57,10 @@ fun QuickSharePermissionBottomSheet( modifier = Modifier.padding(16.dp) ) - PermissionPresetOption.entries.forEach { option -> + options.forEach { option -> ListItem( modifier = Modifier.clickable { onOptionSelected(option) }, - headlineContent = { Text(stringResource(option.labelRes)) }, + headlineContent = { Text(option.label()) }, trailingContent = { if (option == selectedOption) { Icon( 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 04eb864d..e8da6884 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 @@ -7,14 +7,17 @@ package com.nextcloud.android.common.ui.share.model.api.capabilities +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class SharingCapabilities( @SerialName("api_versions") - val apiVersions: List, + val apiVersions: List = emptyList(), val legacy: Legacy? = null, @SerialName("source_types") - val sourceTypes: List, + val sourceTypes: List = emptyList(), + @SerialName("permission_presets") + val permissionPresets: List = emptyList(), ) 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 3dd22299..ccd405a5 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 @@ -10,6 +10,9 @@ package com.nextcloud.android.common.ui.share.model.api.permission import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** + * [presets] holds the class identifiers of the [PermissionPreset]s this permission belongs to. + */ @Serializable data class Permission( @SerialName("class") @@ -25,7 +28,7 @@ data class Permission( val priority: Int, - val presets: List, + val presets: List, val enabled: Boolean ) 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 index fb4f321c..eb78df95 100644 --- 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 @@ -11,10 +11,12 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -enum class PermissionPreset { - @SerialName("view") - VIEW, +data class PermissionPreset( + @SerialName("class") + val clazz: String, - @SerialName("edit") - EDIT -} + @SerialName("display_name") + val displayName: String, + + val hint: String? = 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 index c39733b3..3ca61b45 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,7 +7,6 @@ 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 @@ -61,7 +60,7 @@ data class UpdateSharePermissionRequest( @Serializable data class UpdateSharePermissionPresetRequest( - val permissionPreset: PermissionPreset + val permissionPresetClass: String ) @Serializable 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 a2882efc..4a6b0482 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 @@ -13,7 +13,6 @@ 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.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 @@ -45,7 +44,7 @@ data class Share( val permissions: List, @SerialName("permission_preset") - val permissionPreset: PermissionPreset? = null + val permissionPreset: String? = null ) { val basicProperties: List get() = properties.filterNot { it.advanced }.sortedBy { it.priority } 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 index 886a0f75..73afcc48 100644 --- 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 @@ -7,19 +7,30 @@ 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 +import com.nextcloud.android.common.ui.share.model.api.share.Share -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); +sealed interface PermissionPresetOption { + + val presetClass: String? + + data object Custom : PermissionPresetOption { + override val presetClass: String? = null + } + + data class Preset(val preset: PermissionPreset) : PermissionPresetOption { + override val presetClass: String get() = preset.clazz + } companion object { - fun from(preset: PermissionPreset?): PermissionPresetOption = - entries.firstOrNull { it.preset == preset } ?: CUSTOM + fun optionsFor(share: Share, presets: List): List { + val applicableClasses = share.permissions.flatMap { it.presets }.toSet() + return presets.filter { it.clazz in applicableClasses }.map { Preset(it) } + Custom + } + + fun from(presetClass: String?, presets: List): PermissionPresetOption { + if (presetClass == null) return Custom + return presets.firstOrNull { it.clazz == presetClass }?.let { Preset(it) } ?: 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 4de05ce4..ea33a9b5 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 @@ -10,7 +10,6 @@ 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.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 @@ -46,21 +45,21 @@ class MockShareRepository : ShareRepository { clazz = "read", displayName = "Read", priority = 10, - presets = listOf(PermissionPreset.VIEW, PermissionPreset.EDIT), + presets = listOf("view", "edit"), enabled = true ), Permission( clazz = "update", displayName = "Update", priority = 20, - presets = listOf(PermissionPreset.EDIT), + presets = listOf("edit"), enabled = false ), Permission( clazz = "delete", displayName = "Delete", priority = 30, - presets = listOf(PermissionPreset.EDIT), + presets = listOf("edit"), enabled = false ) ) @@ -474,7 +473,7 @@ class MockShareRepository : ShareRepository { val current = if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList()) val updated = current.copy( - permissionPreset = request.permissionPreset, + permissionPreset = request.permissionPresetClass, lastUpdated = System.currentTimeMillis() ) if (index >= 0) mockShares[index] = updated diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index faa52182..032954aa 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -48,8 +48,6 @@ Change permission Participants Can… - Can view - Can edit Invited people Anyone From 125d3e57dee4f8737fa1779646fdf17d6446c604 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 14 Jul 2026 12:36:28 +0200 Subject: [PATCH 106/144] wip Signed-off-by: alperozturk96 --- .../capabilities/SharingCapabilitiesParser.kt | 19 +++++++++++++++++++ .../model/ui/PermissionPresetOptionLabel.kt | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilitiesParser.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOptionLabel.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilitiesParser.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilitiesParser.kt new file mode 100644 index 00000000..19a2f692 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilitiesParser.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 com.nextcloud.android.common.ui.network.serialization.OCSSerializer + +object SharingCapabilitiesParser { + fun parse(sharingJson: String?): SharingCapabilities? { + if (sharingJson.isNullOrEmpty()) return null + return runCatching { + OCSSerializer.json.decodeFromString(sharingJson) + }.getOrNull() + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOptionLabel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOptionLabel.kt new file mode 100644 index 00000000..f36a5e28 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOptionLabel.kt @@ -0,0 +1,18 @@ +/* + * 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.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.nextcloud.android.common.ui.R + +@Composable +fun PermissionPresetOption.label(): String = when (this) { + PermissionPresetOption.Custom -> stringResource(R.string.share_view_permission_preset_custom) + is PermissionPresetOption.Preset -> preset.displayName +} From 271b3fd3220ae3164ba7a795dd68809d01cb2ce5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 14 Jul 2026 12:45:42 +0200 Subject: [PATCH 107/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 9 ++++----- .../android/common/ui/share/ShareViewModel.kt | 14 ++++++++++++++ .../capabilities/SharingCapabilitiesParser.kt | 19 ------------------- .../share/repository/MockShareRepository.kt | 12 ++++++++++++ .../share/repository/ShareRemoteRepository.kt | 17 +++++++++++++++++ .../ui/share/repository/ShareRepository.kt | 3 +++ 6 files changed, 50 insertions(+), 24 deletions(-) delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilitiesParser.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 d18348bc..0729b38a 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 @@ -95,12 +95,12 @@ import kotlinx.coroutines.launch private fun ShareScreen( sourceId: String, internalLink: String, - viewModel: ShareViewModel, - permissionPresets: List + viewModel: ShareViewModel ) { val errorMessageId by viewModel.errorMessageId.collectAsState() val screenState by viewModel.state.collectAsState() val activeShare by viewModel.activeShare.collectAsState() + val permissionPresets by viewModel.permissionPresets.collectAsState() val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } val resources = LocalResources.current @@ -400,8 +400,7 @@ fun ComposeView.initShareScreen( sourceId: String, internalLink: String, credentials: ServerCredentials, - colorScheme: ColorScheme, - permissionPresets: List = emptyList() + colorScheme: ColorScheme ) { val nextcloudHttpClient = NextcloudHttpClient.create(credentials) val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)) @@ -416,7 +415,7 @@ fun ComposeView.initShareScreen( MaterialTheme( colorScheme = colorScheme, content = { - ShareScreen(sourceId, internalLink, viewModel, permissionPresets) + ShareScreen(sourceId, internalLink, 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 234b74c2..53d1134b 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,6 +12,7 @@ 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 @@ -63,6 +64,9 @@ class ShareViewModel( private val _recipientSearchResults = MutableStateFlow>(emptyList()) val recipientSearchResults: StateFlow> = _recipientSearchResults + private val _permissionPresets = MutableStateFlow>(emptyList()) + val permissionPresets: StateFlow> = _permissionPresets + private val _errorMessageId = MutableStateFlow(null) val errorMessageId: StateFlow = _errorMessageId @@ -79,6 +83,16 @@ class ShareViewModel( init { fetchShares() initSearchQuery() + fetchSharingCapabilities() + } + + private fun fetchSharingCapabilities() { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.fetchSharingCapabilities() + if (result is NetworkResult.Success) { + _permissionPresets.update { result.data.permissionPresets } + } + } } // region search query diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilitiesParser.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilitiesParser.kt deleted file mode 100644 index 19a2f692..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilitiesParser.kt +++ /dev/null @@ -1,19 +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.network.serialization.OCSSerializer - -object SharingCapabilitiesParser { - fun parse(sharingJson: String?): SharingCapabilities? { - if (sharingJson.isNullOrEmpty()) return null - return runCatching { - OCSSerializer.json.decodeFromString(sharingJson) - }.getOrNull() - } -} 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 ea33a9b5..25876385 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,8 +8,10 @@ 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.capabilities.SharingCapabilities import com.nextcloud.android.common.ui.share.model.api.icon.Icon 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 @@ -479,4 +481,14 @@ class MockShareRepository : ShareRepository { if (index >= 0) mockShares[index] = updated return NetworkResult.Success(updated) } + + override suspend fun fetchSharingCapabilities(): NetworkResult = + NetworkResult.Success( + SharingCapabilities( + permissionPresets = listOf( + PermissionPreset(clazz = "view", displayName = "Can view"), + PermissionPreset(clazz = "edit", displayName = "Can edit") + ) + ) + ) } 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 8a841e81..d81dc7b5 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,6 +13,7 @@ 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.capabilities.SharingCapabilities 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 @@ -23,6 +24,9 @@ import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareProper 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 kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject import okhttp3.RequestBody.Companion.toRequestBody import java.net.URLEncoder @@ -38,6 +42,9 @@ class ShareRemoteRepository( 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" + private const val CAPABILITIES_ENDPOINT = "/ocs/v2.php/cloud/capabilities" + private const val NODE_CAPABILITIES = "capabilities" + private const val NODE_SHARING = "sharing" } override suspend fun fetchRecipients( @@ -226,4 +233,14 @@ class ShareRemoteRepository( ) { body -> json.decodeFromString>(body).ocs.data } + + override suspend fun fetchSharingCapabilities(): NetworkResult = + client.executeRequest( + endpoint = CAPABILITIES_ENDPOINT, + method = HttpMethod.GET + ) { body -> + val sharing = json.decodeFromString>(body) + .ocs.data[NODE_CAPABILITIES]?.jsonObject?.get(NODE_SHARING) + sharing?.let { json.decodeFromJsonElement(it) } ?: SharingCapabilities() + } } 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 b6c9d6a0..118311e7 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,6 +7,7 @@ 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.capabilities.SharingCapabilities 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 @@ -92,4 +93,6 @@ interface ShareRepository { ): NetworkResult suspend fun generateSecret(): NetworkResult + + suspend fun fetchSharingCapabilities(): NetworkResult } From f7b468dedc3cb1276a42d9103ab21af3b1f001e1 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 14 Jul 2026 14:18:17 +0200 Subject: [PATCH 108/144] wip Signed-off-by: alperozturk96 --- .../ui/network/http/NextcloudHttpClient.kt | 10 +++++++--- .../android/common/ui/share/ShareViewModel.kt | 17 +++++++++++++++-- .../bottomsheet/AddOrEditShareBottomSheet.kt | 8 ++++++-- 3 files changed, 28 insertions(+), 7 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 2cbfecc8..ff1e3b26 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 @@ -18,6 +18,7 @@ import com.nextcloud.android.common.ui.network.serialization.OCSSerializer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive import okhttp3.OkHttpClient import okhttp3.RequestBody import java.util.concurrent.TimeUnit @@ -74,14 +75,17 @@ class NextcloudHttpClient private constructor( } } - val meta = OCSSerializer.json + val ocs = OCSSerializer.json .decodeFromString>(responseBody) - .ocs.meta + .ocs + val meta = ocs.meta val isError = !response.isSuccessful || meta.status != OCS_OK debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = isError) if (isError) { - NetworkResult.ServerError(OcsResponse(Ocs(meta, meta.message))) + val dataMessage = (ocs.data as? JsonPrimitive)?.takeIf { it.isString }?.content + val message = dataMessage?.takeIf { it.isNotBlank() } ?: meta.message + NetworkResult.ServerError(OcsResponse(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 53d1134b..9b6fd788 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 @@ -73,6 +73,9 @@ class ShareViewModel( private val _propertyErrors = MutableStateFlow>(emptyMap()) val propertyErrors: StateFlow> = _propertyErrors + private val _pendingProperties = MutableStateFlow>(emptySet()) + val pendingProperties: StateFlow> = _pendingProperties + private val propertyUpdateJobs = mutableMapOf() private var secretUpdateJob: Job? = null @@ -290,6 +293,10 @@ class ShareViewModel( // region properties fun updateProperty(shareId: String, clazz: String, value: String?) { propertyUpdateJobs[clazz]?.cancel() + _pendingProperties.update { it + clazz } + if (value.isNullOrEmpty()) { + _propertyErrors.update { it - clazz } + } propertyUpdateJobs[clazz] = viewModelScope.launch(Dispatchers.IO) { delay(PROPERTY_DEBOUNCE_DELAY.milliseconds) when (val result = repository.updateShareProperty(shareId, UpdateSharePropertyRequest(clazz, value))) { @@ -300,13 +307,18 @@ class ShareViewModel( } is NetworkResult.ServerError -> { - _propertyErrors.update { it + (clazz to result.response.ocs.meta.message) } + if (!value.isNullOrEmpty()) { + _propertyErrors.update { it + (clazz to result.response.ocs.data) } + } } is NetworkResult.NetworkException -> { - _propertyErrors.update { it + (clazz to null) } + if (!value.isNullOrEmpty()) { + _propertyErrors.update { it + (clazz to null) } + } } } + _pendingProperties.update { it - clazz } } } // endregion @@ -361,6 +373,7 @@ class ShareViewModel( fun setActiveShare(value: Share?) { _propertyErrors.update { emptyMap() } + _pendingProperties.update { emptySet() } _activeShare.update { value?.toActiveShare() ?: ActiveShareState.None } } // 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 e6d25be6..9607793b 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 @@ -94,7 +94,9 @@ fun AddOrEditShareBottomSheet( var showAdvancedSettings by remember(share.id) { mutableStateOf(entry == ShareEditorEntry.SEND_EMAIL) } val context = LocalContext.current val propertyErrors by viewModel.propertyErrors.collectAsState() - val hasPropertyErrors = propertyErrors.values.any { it != null } + val pendingProperties by viewModel.pendingProperties.collectAsState() + val hasPropertyErrors = propertyErrors.isNotEmpty() + val sendEnabled = !hasPropertyErrors && pendingProperties.isEmpty() Column( modifier = Modifier @@ -172,7 +174,7 @@ fun AddOrEditShareBottomSheet( share = share, internalLink = internalLink, category = selectedCategory, - sendEnabled = !hasPropertyErrors, + sendEnabled = sendEnabled, viewModel = viewModel ) } @@ -384,6 +386,7 @@ private fun ActionButtons( } } }, + enabled = category != ShareCategory.Anyone || sendEnabled, modifier = Modifier.weight(1f), shape = RoundedCornerShape(10.dp), colors = ButtonDefaults.buttonColors( @@ -430,6 +433,7 @@ private fun ActionButtons( onClick = { viewModel.updateState(share.id, ShareState.ACTIVE) }, + enabled = sendEnabled, modifier = Modifier.weight(1f), shape = RoundedCornerShape(10.dp), colors = ButtonDefaults.buttonColors( From 9133b647c832b124f4094c067263bdd86557ab23 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 14 Jul 2026 14:28:35 +0200 Subject: [PATCH 109/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 10 ++- .../share/component/SelectRecipientField.kt | 1 - .../component/property/SharePropertyView.kt | 86 +++++++++++-------- .../common/ui/share/model/api/share/Share.kt | 17 ++-- .../share/repository/ShareRemoteRepository.kt | 47 +++------- 5 files changed, 79 insertions(+), 82 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 0729b38a..bce5a019 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 @@ -225,12 +225,14 @@ private fun ShareItem( ) { var overlayState by remember { mutableStateOf(ShareItemOverlayState.None) } val haptics = LocalHapticFeedback.current + val presetOptions = PermissionPresetOption.optionsFor(share, permissionPresets) + val selectedPresetOption = PermissionPresetOption.from(share.permissionPreset, permissionPresets) when (overlayState) { is ShareItemOverlayState.QuickShare -> { QuickSharePermissionBottomSheet( - options = PermissionPresetOption.optionsFor(share, permissionPresets), - selectedOption = PermissionPresetOption.from(share.permissionPreset, permissionPresets), + options = presetOptions, + selectedOption = selectedPresetOption, onOptionSelected = { option -> overlayState = ShareItemOverlayState.None val presetClass = option.presetClass @@ -292,7 +294,7 @@ private fun ShareItem( }, supportingContent = { val chipHorizontalPadding = 10.dp - val selectedLabel = PermissionPresetOption.from(share.permissionPreset, permissionPresets).label() + val selectedLabel = selectedPresetOption.label() Row( modifier = Modifier @@ -304,7 +306,7 @@ private fun ShareItem( verticalAlignment = Alignment.CenterVertically ) { Box(contentAlignment = Alignment.CenterStart) { - PermissionPresetOption.optionsFor(share, permissionPresets).forEach { option -> + presetOptions.forEach { option -> Text( text = option.label(), style = MaterialTheme.typography.bodyMedium, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt index bbb8ac83..8479e13f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt @@ -7,7 +7,6 @@ package com.nextcloud.android.common.ui.share.component -import android.graphics.Color import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth 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 a5318ddb..fb4b9ec0 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 @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel @@ -68,43 +69,25 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM } } - is PropertyString -> { - var textValue by remember(property.clazz) { mutableStateOf(property.value ?: "") } + is PropertyString -> ShareTextPropertyField( + shareId = shareId, + clazz = property.clazz, + displayName = property.displayName, + initialValue = property.value ?: "", + errorMessage = errorMessage, + viewModel = viewModel + ) - OutlinedTextField( - value = textValue, - onValueChange = { - textValue = it - viewModel.updateProperty(shareId, property.clazz, it) - }, - label = { Text(property.displayName) }, - isError = errorMessage != null, - supportingText = errorMessage?.let { { Text(it) } }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - singleLine = true - ) - } - - is PropertyPassword -> { - var passwordValue by remember(property.clazz) { mutableStateOf(property.value ?: "") } - - OutlinedTextField( - value = passwordValue, - onValueChange = { - passwordValue = it - viewModel.updateProperty(shareId, property.clazz, it) - }, - label = { Text(property.displayName) }, - placeholder = property.hint?.let { { Text(it) } }, - visualTransformation = PasswordVisualTransformation(), - isError = errorMessage != null, - supportingText = errorMessage?.let { { Text(it) } }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - singleLine = true - ) - } + is PropertyPassword -> ShareTextPropertyField( + shareId = shareId, + clazz = property.clazz, + displayName = property.displayName, + initialValue = property.value ?: "", + errorMessage = errorMessage, + viewModel = viewModel, + hint = property.hint, + visualTransformation = PasswordVisualTransformation() + ) is PropertyDate -> { ShareDatePicker(property, errorMessage = errorMessage, onDateSelected = { dateValue -> @@ -121,3 +104,34 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM } } } + +@Composable +private fun ShareTextPropertyField( + shareId: String, + clazz: String, + displayName: String, + initialValue: String, + errorMessage: String?, + viewModel: ShareViewModel, + hint: String? = null, + visualTransformation: VisualTransformation = VisualTransformation.None +) { + var value by remember(clazz) { mutableStateOf(initialValue) } + + OutlinedTextField( + value = value, + onValueChange = { + value = it + viewModel.updateProperty(shareId, clazz, it) + }, + label = { Text(displayName) }, + placeholder = hint?.let { { Text(it) } }, + visualTransformation = visualTransformation, + isError = errorMessage != null, + supportingText = errorMessage?.let { { Text(it) } }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + singleLine = true + ) +} 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 4a6b0482..7d807e5a 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,11 +46,13 @@ data class Share( @SerialName("permission_preset") val permissionPreset: String? = null ) { - val basicProperties: List - get() = properties.filterNot { it.advanced }.sortedBy { it.priority } + val basicProperties: List by lazy { + properties.filterNot { it.advanced }.sortedBy { it.priority } + } - val advancedProperties: List - get() = properties.filter { it.advanced }.sortedBy { it.priority } + val advancedProperties: List by lazy { + properties.filter { it.advanced }.sortedBy { it.priority } + } val isBasicSectionAvailable: Boolean get() { @@ -96,10 +98,9 @@ data class Share( return customLinkRecipient != null } - val customLinkRecipient: Recipient? - get() { - return recipients.firstOrNull { it.secret.updatable } - } + val customLinkRecipient: Recipient? by lazy { + recipients.firstOrNull { it.secret.updatable } + } val canSend: Boolean get() { 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 d81dc7b5..a454e806 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 @@ -47,6 +47,9 @@ class ShareRemoteRepository( private const val NODE_SHARING = "sharing" } + private fun decodeShare(body: String): Share = + json.decodeFromString>(body).ocs.data + override suspend fun fetchRecipients( recipientTypeClasses: List?, query: String, @@ -70,9 +73,7 @@ class ShareRemoteRepository( endpoint = SHARE_ENDPOINT, method = HttpMethod.POST, body = ByteArray(0).toRequestBody() - ) { body -> - json.decodeFromString>(body).ocs.data - } + ) { decodeShare(it) } override suspend fun fetchShare( id: String, @@ -82,9 +83,7 @@ class ShareRemoteRepository( endpoint = "$SHARE_ENDPOINT/$id", method = HttpMethod.POST, body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) - ) { body -> - json.decodeFromString>(body).ocs.data - } + ) { decodeShare(it) } override suspend fun deleteShare(id: String): NetworkResult = client.executeRequest( @@ -120,9 +119,7 @@ class ShareRemoteRepository( endpoint = "$SHARE_ENDPOINT/$id/state", method = HttpMethod.PUT, body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) - ) { body -> - json.decodeFromString>(body).ocs.data - } + ) { decodeShare(it) } override suspend fun addShareSource( id: String, @@ -132,9 +129,7 @@ class ShareRemoteRepository( endpoint = "$SHARE_ENDPOINT/$id/source", method = HttpMethod.POST, body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) - ) { body -> - json.decodeFromString>(body).ocs.data - } + ) { decodeShare(it) } override suspend fun removeShareSource( id: String, @@ -144,9 +139,7 @@ class ShareRemoteRepository( client.executeRequest( endpoint = "$SHARE_ENDPOINT/$id/source?class=${clazz.urlEncoded()}&value=${value.urlEncoded()}", method = HttpMethod.DELETE - ) { body -> - json.decodeFromString>(body).ocs.data - } + ) { decodeShare(it) } override suspend fun addShareRecipient( id: String, @@ -156,9 +149,7 @@ class ShareRemoteRepository( endpoint = "$SHARE_ENDPOINT/$id/recipient", method = HttpMethod.POST, body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) - ) { body -> - json.decodeFromString>(body).ocs.data - } + ) { decodeShare(it) } override suspend fun removeShareRecipient( id: String, @@ -173,9 +164,7 @@ class ShareRemoteRepository( return client.executeRequest( endpoint = "$SHARE_ENDPOINT/$id/recipient$queryParams", method = HttpMethod.DELETE - ) { body -> - json.decodeFromString>(body).ocs.data - } + ) { decodeShare(it) } } override suspend fun updateShareProperty( @@ -186,9 +175,7 @@ class ShareRemoteRepository( endpoint = "$SHARE_ENDPOINT/$id/property", method = HttpMethod.PUT, body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) - ) { body -> - json.decodeFromString>(body).ocs.data - } + ) { decodeShare(it) } override suspend fun updateSharePermission( id: String, @@ -198,9 +185,7 @@ class ShareRemoteRepository( endpoint = "$SHARE_ENDPOINT/$id/permission", method = HttpMethod.PUT, body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) - ) { body -> - json.decodeFromString>(body).ocs.data - } + ) { decodeShare(it) } override suspend fun updateSharePermissionPreset( id: String, @@ -210,9 +195,7 @@ class ShareRemoteRepository( endpoint = "$SHARE_ENDPOINT/$id/permission/preset", method = HttpMethod.PUT, body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) - ) { body -> - json.decodeFromString>(body).ocs.data - } + ) { decodeShare(it) } override suspend fun updateShareRecipientSecret( id: String, @@ -222,9 +205,7 @@ class ShareRemoteRepository( endpoint = "$SHARE_ENDPOINT/$id/recipient/secret", method = HttpMethod.PUT, body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) - ) { body -> - json.decodeFromString>(body).ocs.data - } + ) { decodeShare(it) } override suspend fun generateSecret(): NetworkResult = client.executeRequest( From 198af257917ed27a5db8b8976020d415154672ac Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Jul 2026 12:27:18 +0200 Subject: [PATCH 110/144] wip Signed-off-by: alperozturk96 --- .../ui/share/model/api/capabilities/Legacy.kt | 19 ------------------- .../api/capabilities/SharingCapabilities.kt | 1 - 2 files changed, 20 deletions(-) delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/Legacy.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 deleted file mode 100644 index cef0c5b9..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/Legacy.kt +++ /dev/null @@ -1,19 +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 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/SharingCapabilities.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt index e8da6884..27c52b3b 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,7 +15,6 @@ import kotlinx.serialization.Serializable data class SharingCapabilities( @SerialName("api_versions") val apiVersions: List = emptyList(), - val legacy: Legacy? = null, @SerialName("source_types") val sourceTypes: List = emptyList(), @SerialName("permission_presets") From eaf7a333c6e9af7764a71dc116280d0e1d8c7caa Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Jul 2026 13:33:18 +0200 Subject: [PATCH 111/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 4 +++- .../android/common/ui/share/model/api/share/Share.kt | 11 +++++++++++ .../ui/share/model/ui/PermissionPresetOption.kt | 6 +++++- 3 files changed, 19 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 9b6fd788..263d492f 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 @@ -93,7 +93,9 @@ class ShareViewModel( viewModelScope.launch(Dispatchers.IO) { val result = repository.fetchSharingCapabilities() if (result is NetworkResult.Success) { - _permissionPresets.update { result.data.permissionPresets } + _permissionPresets.update { + result.data.permissionPresets + } } } } 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 7d807e5a..072884ab 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 @@ -102,6 +102,17 @@ data class Share( recipients.firstOrNull { it.secret.updatable } } + val defaultPermissionPresetClass: String? by lazy { + val enabledClasses = permissions.filter { it.enabled }.map { it.clazz }.toSet() + if (enabledClasses.isEmpty()) { + null + } else { + permissions.flatMap { it.presets }.distinct().firstOrNull { presetClass -> + permissions.filter { presetClass in it.presets }.map { it.clazz }.toSet() == enabledClasses + } + } + } + val canSend: Boolean get() { return sources.isNotEmpty() && 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 index 73afcc48..13f1435d 100644 --- 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 @@ -25,7 +25,11 @@ sealed interface PermissionPresetOption { companion object { fun optionsFor(share: Share, presets: List): List { val applicableClasses = share.permissions.flatMap { it.presets }.toSet() - return presets.filter { it.clazz in applicableClasses }.map { Preset(it) } + Custom + val defaultClass = share.defaultPermissionPresetClass + return presets + .filter { it.clazz in applicableClasses } + .sortedByDescending { it.clazz == defaultClass } + .map { Preset(it) } + Custom } fun from(presetClass: String?, presets: List): PermissionPresetOption { From c8f54202a5a467725e123fe945d4e606cb6f3bce Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Jul 2026 13:41:52 +0200 Subject: [PATCH 112/144] wip Signed-off-by: alperozturk96 --- .../property/datepicker/ShareDatePicker.kt | 5 ++++- .../datepicker/util/NoPastSelectableDates.kt | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/NoPastSelectableDates.kt 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 6c8b6f51..153e6f51 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 @@ -28,6 +28,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource 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.NoPastSelectableDates import com.nextcloud.android.common.ui.share.component.property.datepicker.util.ShareDateFormatter import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate @@ -80,7 +81,9 @@ private fun DatePickerModal( onDateSelected: (displayDate: String?, isoDate: String?) -> Unit, onDismiss: () -> Unit ) { - val datePickerState = rememberDatePickerState() + val datePickerState = rememberDatePickerState( + selectableDates = remember { NoPastSelectableDates() } + ) DatePickerDialog( onDismissRequest = onDismiss, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/NoPastSelectableDates.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/NoPastSelectableDates.kt new file mode 100644 index 00000000..0fdc2c3c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/NoPastSelectableDates.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.property.datepicker.util + +import androidx.compose.material3.SelectableDates +import java.time.LocalDate +import java.time.ZoneOffset + +class NoPastSelectableDates : SelectableDates { + + private val today: LocalDate = LocalDate.now(ZoneOffset.UTC) + private val todayUtcMillis: Long = today.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + + override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= todayUtcMillis + + override fun isSelectableYear(year: Int): Boolean = year >= today.year +} From ae5a40aebc0ffdcef8d361a049360a87d430a9c7 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Jul 2026 13:51:56 +0200 Subject: [PATCH 113/144] wip Signed-off-by: alperozturk96 --- .../datepicker/util/ShareDateFormatterTest.kt | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 ui/src/test/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatterTest.kt diff --git a/ui/src/test/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatterTest.kt b/ui/src/test/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatterTest.kt new file mode 100644 index 00000000..c4661a51 --- /dev/null +++ b/ui/src/test/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatterTest.kt @@ -0,0 +1,82 @@ +/* + * 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.datepicker.util + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Before +import org.junit.Test +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +class ShareDateFormatterTest { + + private lateinit var originalLocale: Locale + + @Before + fun setUp() { + originalLocale = Locale.getDefault() + } + + @After + fun tearDown() { + Locale.setDefault(originalLocale) + } + + @Test + fun `displays expiration date with Turkish format when device locale is Turkish`() { + assertLocalizedDisplay(TURKISH) + } + + @Test + fun `displays expiration date with English format when device locale is English`() { + assertLocalizedDisplay(ENGLISH) + } + + @Test + fun `displays expiration date with device locale format for other languages`() { + assertLocalizedDisplay(GERMAN) + } + + @Test + fun `expiration date display differs per device locale`() { + val turkish = displayFor(TURKISH) + val english = displayFor(ENGLISH) + val german = displayFor(GERMAN) + + assertNotEquals(turkish, english) + assertNotEquals(turkish, german) + assertNotEquals(english, german) + } + + private fun assertLocalizedDisplay(locale: Locale) { + val expected = DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + .withLocale(locale) + .format(DATE) + + assertEquals(expected, displayFor(locale)) + } + + private fun displayFor(locale: Locale): String { + Locale.setDefault(locale) + return ShareDateFormatter().formatIsoForDisplay(ISO_DATE) + } + + private companion object { + private val TURKISH: Locale = Locale.forLanguageTag("tr") + private val ENGLISH: Locale = Locale.forLanguageTag("en") + private val GERMAN: Locale = Locale.forLanguageTag("de") + + private const val ISO_DATE = "2026-12-31" + private val DATE: LocalDate = LocalDate.of(2026, 12, 31) + } +} From 50fd837a3028f44a5f4051575f16260a5820dfbe Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Jul 2026 14:44:21 +0200 Subject: [PATCH 114/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 4 - .../common/ui/share/component/CustomLink.kt | 83 ++++++++++++------- .../component/property/SharePropertyView.kt | 17 ++-- 3 files changed, 67 insertions(+), 37 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 263d492f..6fb916bc 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 @@ -32,7 +32,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.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce @@ -50,7 +49,6 @@ class ShareViewModel( companion object { private const val SEARCH_DEBOUNCE_DELAY = 300L - private const val PROPERTY_DEBOUNCE_DELAY = 1000L } private val _state = MutableStateFlow(ShareScreenState.Loading) @@ -270,7 +268,6 @@ class ShareViewModel( 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, @@ -300,7 +297,6 @@ class ShareViewModel( _propertyErrors.update { it - clazz } } 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 } 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 8c581c52..e747b63e 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 @@ -24,6 +24,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -39,13 +40,11 @@ fun CustomLink( 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() if (token.isNotEmpty() && url.endsWith(token)) url.removeSuffix(token) else url } - var token by remember(recipient.value) { mutableStateOf(recipient.secret.value ?: "") } Column( modifier = Modifier @@ -66,32 +65,60 @@ fun CustomLink( modifier = Modifier.padding(top = 4.dp, bottom = 8.dp) ) - OutlinedTextField( - value = token, - onValueChange = { newValue -> - val trimmed = newValue.take(MAX_TOKEN_LENGTH) - token = trimmed - if (trimmed.isNotBlank()) onTokenChange(trimmed) - }, - label = { Text(prefix) }, - singleLine = true, - trailingIcon = { - IconButton( - onClick = { - scope.launch { - val generated = onGenerateSecret() ?: return@launch - token = generated - onTokenChange(generated) - } - } - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = stringResource(R.string.share_view_custom_link_refresh) - ) - } - }, - modifier = Modifier.fillMaxWidth() + CustomLinkTokenField( + initialToken = recipient.secret.value ?: "", + prefix = prefix, + onGenerateSecret = onGenerateSecret, + onCommit = onTokenChange ) } } + +@Composable +private fun CustomLinkTokenField( + initialToken: String, + prefix: String, + onGenerateSecret: suspend () -> String?, + onCommit: (String) -> Unit +) { + val scope = rememberCoroutineScope() + var token by remember(initialToken) { mutableStateOf(initialToken) } + var committedToken by remember(initialToken) { mutableStateOf(initialToken) } + var wasFocused by remember(initialToken) { mutableStateOf(false) } + + fun commit() { + if (token == committedToken || token.isBlank()) return + committedToken = token + onCommit(token) + } + + OutlinedTextField( + value = token, + onValueChange = { newValue -> token = newValue.take(MAX_TOKEN_LENGTH) }, + label = { Text(prefix) }, + singleLine = true, + trailingIcon = { + IconButton( + onClick = { + scope.launch { + val generated = onGenerateSecret() ?: return@launch + token = generated + committedToken = generated + onCommit(generated) + } + } + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(R.string.share_view_custom_link_refresh) + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + if (wasFocused && !focusState.isFocused) commit() + wasFocused = focusState.isFocused + } + ) +} 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 fb4b9ec0..c181e5d4 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,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -117,13 +118,12 @@ private fun ShareTextPropertyField( visualTransformation: VisualTransformation = VisualTransformation.None ) { var value by remember(clazz) { mutableStateOf(initialValue) } + var committedValue by remember(clazz) { mutableStateOf(initialValue) } + var wasFocused by remember(clazz) { mutableStateOf(false) } OutlinedTextField( value = value, - onValueChange = { - value = it - viewModel.updateProperty(shareId, clazz, it) - }, + onValueChange = { value = it }, label = { Text(displayName) }, placeholder = hint?.let { { Text(it) } }, visualTransformation = visualTransformation, @@ -131,7 +131,14 @@ private fun ShareTextPropertyField( supportingText = errorMessage?.let { { Text(it) } }, modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), + .padding(vertical = 4.dp) + .onFocusChanged { focusState -> + if (wasFocused && !focusState.isFocused && value != committedValue) { + committedValue = value + viewModel.updateProperty(shareId, clazz, value) + } + wasFocused = focusState.isFocused + }, singleLine = true ) } From 3c7029f0791e085b1a9a2d66d4b19c4a32a8f8b6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Jul 2026 15:40:34 +0200 Subject: [PATCH 115/144] wip Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 3 +- .../common/ui/share/model/api/share/Share.kt | 21 ++-- .../share/model/ui/PermissionPresetOption.kt | 6 +- .../ShareDefaultPermissionPresetOptionTest.kt | 102 ++++++++++++++++++ 4 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 ui/src/test/java/com/nextcloud/android/common/ui/share/model/api/share/ShareDefaultPermissionPresetOptionTest.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 9607793b..bea931a0 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 @@ -219,7 +219,8 @@ private fun PermissionsView( viewModel: ShareViewModel ) { var selectedPreset by remember(share.id) { - mutableStateOf(if (initialPresetOption != null) initialPresetOption.presetClass else share.permissionPreset) + val default = share.getDefaultPermissionPresetOption(permissionPresets).presetClass + mutableStateOf(initialPresetOption?.presetClass ?: share.permissionPreset ?: default) } PermissionPresetDropdown( 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 072884ab..0f3a303d 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 @@ -13,12 +13,14 @@ 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.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 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.ShareCategory import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -102,15 +104,18 @@ data class Share( recipients.firstOrNull { it.secret.updatable } } - val defaultPermissionPresetClass: String? by lazy { - val enabledClasses = permissions.filter { it.enabled }.map { it.clazz }.toSet() - if (enabledClasses.isEmpty()) { - null - } else { - permissions.flatMap { it.presets }.distinct().firstOrNull { presetClass -> - permissions.filter { presetClass in it.presets }.map { it.clazz }.toSet() == enabledClasses - } + // The preset matching the enabled permissions dictated by the server admin: + // only the read permission -> "Can view", every permission -> "Can edit", any other subset -> "Can ...". + fun getDefaultPermissionPresetOption(permissionPresets: List): PermissionPresetOption { + val presetControlled = permissions.filter { it.presets.isNotEmpty() } + val enabledClasses = presetControlled.filter { it.enabled }.map { it.clazz }.toSet() + if (enabledClasses.isEmpty()) return PermissionPresetOption.Custom + + val matchingPreset = permissionPresets.firstOrNull { preset -> + presetControlled.filter { preset.clazz in it.presets }.map { it.clazz }.toSet() == enabledClasses } + + return matchingPreset?.let { PermissionPresetOption.Preset(it) } ?: PermissionPresetOption.Custom } val canSend: Boolean 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 index 13f1435d..73afcc48 100644 --- 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 @@ -25,11 +25,7 @@ sealed interface PermissionPresetOption { companion object { fun optionsFor(share: Share, presets: List): List { val applicableClasses = share.permissions.flatMap { it.presets }.toSet() - val defaultClass = share.defaultPermissionPresetClass - return presets - .filter { it.clazz in applicableClasses } - .sortedByDescending { it.clazz == defaultClass } - .map { Preset(it) } + Custom + return presets.filter { it.clazz in applicableClasses }.map { Preset(it) } + Custom } fun from(presetClass: String?, presets: List): PermissionPresetOption { diff --git a/ui/src/test/java/com/nextcloud/android/common/ui/share/model/api/share/ShareDefaultPermissionPresetOptionTest.kt b/ui/src/test/java/com/nextcloud/android/common/ui/share/model/api/share/ShareDefaultPermissionPresetOptionTest.kt new file mode 100644 index 00000000..ec43d7a5 --- /dev/null +++ b/ui/src/test/java/com/nextcloud/android/common/ui/share/model/api/share/ShareDefaultPermissionPresetOptionTest.kt @@ -0,0 +1,102 @@ +/* + * 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.icon.Icon +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.state.ShareState +import com.nextcloud.android.common.ui.share.model.api.user.User +import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption +import org.junit.Assert.assertEquals +import org.junit.Test + +class ShareDefaultPermissionPresetOptionTest { + + @Test + fun `returns view preset when only read is enabled`() { + val share = shareWithEnabled(read = true, update = false, delete = false) + + val result = share.getDefaultPermissionPresetOption(PRESETS) + + assertEquals(PermissionPresetOption.Preset(VIEW_PRESET), result) + } + + @Test + fun `returns edit preset when all permissions are enabled`() { + val share = shareWithEnabled(read = true, update = true, delete = true) + + val result = share.getDefaultPermissionPresetOption(PRESETS) + + assertEquals(PermissionPresetOption.Preset(EDIT_PRESET), result) + } + + @Test + fun `returns custom when only some permissions are enabled`() { + val share = shareWithEnabled(read = true, update = true, delete = false) + + val result = share.getDefaultPermissionPresetOption(PRESETS) + + assertEquals(PermissionPresetOption.Custom, result) + } + + @Test + fun `returns custom when no permission is enabled`() { + val share = shareWithEnabled(read = false, update = false, delete = false) + + val result = share.getDefaultPermissionPresetOption(PRESETS) + + assertEquals(PermissionPresetOption.Custom, result) + } + + @Test + fun `ignores enabled permissions that belong to no preset`() { + val share = shareWith( + permission("read", listOf("view", "edit"), enabled = true), + permission("update", listOf("edit"), enabled = false), + permission("delete", listOf("edit"), enabled = false), + permission("download", emptyList(), enabled = true) + ) + + val result = share.getDefaultPermissionPresetOption(PRESETS) + + assertEquals(PermissionPresetOption.Preset(VIEW_PRESET), result) + } + + private fun shareWithEnabled(read: Boolean, update: Boolean, delete: Boolean) = shareWith( + permission("read", listOf("view", "edit"), read), + permission("update", listOf("edit"), update), + permission("delete", listOf("edit"), delete) + ) + + private fun shareWith(vararg permissions: Permission) = Share( + id = "1", + owner = OWNER, + lastUpdated = 0L, + shareState = ShareState.DRAFT, + sources = emptyList(), + recipients = emptyList(), + properties = emptyList(), + permissions = permissions.toList() + ) + + private fun permission(clazz: String, presets: List, enabled: Boolean) = Permission( + clazz = clazz, + displayName = clazz, + priority = 1, + presets = presets, + enabled = enabled + ) + + private companion object { + private val VIEW_PRESET = PermissionPreset(clazz = "view", displayName = "Can view") + private val EDIT_PRESET = PermissionPreset(clazz = "edit", displayName = "Can edit") + private val PRESETS = listOf(VIEW_PRESET, EDIT_PRESET) + private val OWNER = User(userId = "owner", displayName = "Owner", icon = Icon()) + } +} From 8b47cbdb74781c4bc6146d5d6da0b11bfecdd897 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 10:11:39 +0200 Subject: [PATCH 116/144] wip Signed-off-by: alperozturk96 --- gradle/verification-metadata.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 922c0cfa..12c02be1 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -99,6 +99,7 @@ + @@ -252,6 +253,7 @@ + @@ -14918,6 +14920,11 @@ + + + + + @@ -19383,6 +19390,11 @@ + + + + + @@ -20888,6 +20900,11 @@ + + + + + From 4b383d32c216fac6fa5b8f8b85af164c3e8073ef Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 10:49:19 +0200 Subject: [PATCH 117/144] wip Signed-off-by: alperozturk96 --- build.gradle | 1 + ui/build.gradle | 6 + .../common/ui/share/ShareSavedState.kt | 44 ++++ .../android/common/ui/share/ShareScreen.kt | 48 ++--- .../android/common/ui/share/ShareViewModel.kt | 193 ++++++++++++------ .../common/ui/share/ShareViewModelFactory.kt | 32 +++ .../share/component/SelectRecipientField.kt | 13 +- .../bottomsheet/AddOrEditShareBottomSheet.kt | 16 +- .../component/property/SharePropertyView.kt | 4 +- .../share/model/ui/ShareItemOverlayState.kt | 10 +- 10 files changed, 251 insertions(+), 116 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/ShareSavedState.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModelFactory.kt diff --git a/build.gradle b/build.gradle index f69b0dd9..fd2f19cf 100644 --- a/build.gradle +++ b/build.gradle @@ -9,6 +9,7 @@ buildscript { ext { daggerVersion = "2.60.1" kotlinVersion = "2.4.10" + lifecycleVersion = "2.11.0" jUnitVersion = "4.13.2" androidXTestVersion = "1.3.0" androidXEspressoTestVersion = "3.7.0" diff --git a/ui/build.gradle b/ui/build.gradle index 9c6c77a3..d7f38168 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -59,6 +59,12 @@ dependencies { implementation("androidx.compose.material:material-icons-core") // endregion + // region lifecycle + api "androidx.lifecycle:lifecycle-viewmodel:$lifecycleVersion" + api "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion" + // endregion + 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") diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareSavedState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareSavedState.kt new file mode 100644 index 00000000..63753bcf --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareSavedState.kt @@ -0,0 +1,44 @@ +/* + * 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.SavedStateHandle +import com.nextcloud.android.common.ui.share.model.ui.ShareEditorEntry +import kotlinx.coroutines.flow.StateFlow + +class ShareSavedState( + private val handle: SavedStateHandle +) { + companion object { + private const val KEY_ACTIVE_SHARE_ID = "active_share_id" + private const val KEY_EDITOR_ENTRY = "editor_entry" + private const val KEY_SEARCH_QUERY = "search_query" + } + + var activeShareId: String? + get() = handle[KEY_ACTIVE_SHARE_ID] + set(value) { + handle[KEY_ACTIVE_SHARE_ID] = value + } + + var editorEntry: ShareEditorEntry + get() = + handle + .get(KEY_EDITOR_ENTRY) + ?.let { name -> ShareEditorEntry.entries.firstOrNull { it.name == name } } + ?: ShareEditorEntry.EDIT + set(value) { + handle[KEY_EDITOR_ENTRY] = value.name + } + + val searchQuery: StateFlow = handle.getStateFlow(KEY_SEARCH_QUERY, "") + + fun updateSearchQuery(query: String) { + handle[KEY_SEARCH_QUERY] = query + } +} 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 bce5a019..58f09f27 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 @@ -46,11 +46,10 @@ import androidx.compose.material3.SnackbarHostState 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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -67,6 +66,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import coil3.svg.SvgDecoder @@ -89,7 +91,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.model.ui.label import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository -import kotlinx.coroutines.launch @Composable private fun ShareScreen( @@ -97,14 +98,13 @@ private fun ShareScreen( internalLink: String, viewModel: ShareViewModel ) { - val errorMessageId by viewModel.errorMessageId.collectAsState() - val screenState by viewModel.state.collectAsState() - val activeShare by viewModel.activeShare.collectAsState() - val permissionPresets by viewModel.permissionPresets.collectAsState() - val scope = rememberCoroutineScope() + val errorMessageId by viewModel.errorMessageId.collectAsStateWithLifecycle() + val screenState by viewModel.state.collectAsStateWithLifecycle() + val activeShare by viewModel.activeShare.collectAsStateWithLifecycle() + val permissionPresets by viewModel.permissionPresets.collectAsStateWithLifecycle() + val editorEntry by viewModel.editorEntry.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val resources = LocalResources.current - var editorEntry by remember { mutableStateOf(ShareEditorEntry.EDIT) } val context = LocalContext.current LaunchedEffect(errorMessageId) { @@ -117,13 +117,7 @@ private fun ShareScreen( Scaffold( floatingActionButton = { FloatingActionButton( - onClick = { - scope.launch { - viewModel.createDraftShare()?.let { - viewModel.addSource(it.id, sourceId) - } - } - }, + onClick = { viewModel.createDraftShare(sourceId) }, ) { Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") } @@ -174,20 +168,17 @@ private fun ShareScreen( type = type, permissionPresets = permissionPresets, onSelectShare = { selected -> - editorEntry = ShareEditorEntry.EDIT - viewModel.setActiveShare(selected) + viewModel.setActiveShare(selected, ShareEditorEntry.EDIT) }, onCustomizeShare = { selected -> - editorEntry = ShareEditorEntry.CUSTOMIZE_PERMISSION - viewModel.setActiveShare(selected) + viewModel.setActiveShare(selected, ShareEditorEntry.CUSTOMIZE_PERMISSION) }, onChangePreset = { selected, preset -> viewModel.updatePermissionPreset(selected.id, preset, updateActiveShare = false) }, onDeleteShare = { viewModel.deleteShare(it.id) }, onSendEmail = { selected -> - editorEntry = ShareEditorEntry.SEND_EMAIL - viewModel.setActiveShare(selected) + viewModel.setActiveShare(selected, ShareEditorEntry.SEND_EMAIL) } ) } @@ -223,13 +214,13 @@ private fun ShareItem( onDeleteShare: (Share) -> Unit, onSendEmail: (Share) -> Unit ) { - var overlayState by remember { mutableStateOf(ShareItemOverlayState.None) } + var overlayState by rememberSaveable(share.id) { mutableStateOf(ShareItemOverlayState.None) } val haptics = LocalHapticFeedback.current val presetOptions = PermissionPresetOption.optionsFor(share, permissionPresets) val selectedPresetOption = PermissionPresetOption.from(share.permissionPreset, permissionPresets) when (overlayState) { - is ShareItemOverlayState.QuickShare -> { + ShareItemOverlayState.QuickShare -> { QuickSharePermissionBottomSheet( options = presetOptions, selectedOption = selectedPresetOption, @@ -246,7 +237,7 @@ private fun ShareItem( ) } - is ShareItemOverlayState.DeleteConfirmation -> { + ShareItemOverlayState.DeleteConfirmation -> { DeleteShareConfirmationDialog( onConfirm = { overlayState = ShareItemOverlayState.None @@ -399,13 +390,16 @@ private fun ShareItem( } fun ComposeView.initShareScreen( + viewModelStoreOwner: ViewModelStoreOwner, sourceId: String, internalLink: String, credentials: ServerCredentials, colorScheme: ColorScheme ) { - val nextcloudHttpClient = NextcloudHttpClient.create(credentials) - val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)) + val factory = ShareViewModelFactory { + ShareRemoteRepository(NextcloudHttpClient.create(credentials)) + } + val viewModel = ViewModelProvider.create(viewModelStoreOwner, factory)[ShareViewModel::class] 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 6fb916bc..d83337e8 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 @@ -7,6 +7,7 @@ package com.nextcloud.android.common.ui.share +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.android.common.ui.R @@ -26,17 +27,22 @@ 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.ShareEditorEntry 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.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -44,23 +50,38 @@ import java.util.UUID import kotlin.time.Duration.Companion.milliseconds class ShareViewModel( - private val repository: ShareRepository + private val repository: ShareRepository, + savedStateHandle: SavedStateHandle ) : ViewModel() { companion object { private const val SEARCH_DEBOUNCE_DELAY = 300L + private const val SEARCH_SUBSCRIPTION_TIMEOUT = 5_000L + private const val RECIPIENT_SEARCH_LIMIT = 10 + private const val RECIPIENT_SEARCH_OFFSET = 0 + private const val SHARES_PAGE_SIZE = 50 } + private val savedState = ShareSavedState(savedStateHandle) + private val _state = MutableStateFlow(ShareScreenState.Loading) val state: StateFlow = _state private val _activeShare = MutableStateFlow(ActiveShareState.None) val activeShare: StateFlow = _activeShare - private val _searchQuery = MutableStateFlow("") + private val _editorEntry = MutableStateFlow(savedState.editorEntry) + val editorEntry: StateFlow = _editorEntry + + val searchQuery: StateFlow = savedState.searchQuery - private val _recipientSearchResults = MutableStateFlow>(emptyList()) - val recipientSearchResults: StateFlow> = _recipientSearchResults + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val recipientSearchResults: StateFlow> = searchQuery + .debounce(SEARCH_DEBOUNCE_DELAY.milliseconds) + .distinctUntilChanged() + .filter { it.isNotBlank() } + .mapLatest { query -> searchRecipients(query) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SEARCH_SUBSCRIPTION_TIMEOUT), emptyList()) private val _permissionPresets = MutableStateFlow>(emptyList()) val permissionPresets: StateFlow> = _permissionPresets @@ -82,43 +103,48 @@ class ShareViewModel( get() = (_state.value as? ShareScreenState.Loaded)?.shares ?: emptyList() init { - fetchShares() - initSearchQuery() - fetchSharingCapabilities() + loadInitialData() } - private fun fetchSharingCapabilities() { + private fun loadInitialData() { viewModelScope.launch(Dispatchers.IO) { - val result = repository.fetchSharingCapabilities() - if (result is NetworkResult.Success) { - _permissionPresets.update { - result.data.permissionPresets - } - } + launch { loadSharingCapabilities() } + launch { restoreActiveShare() } + loadShares() } } - // region search query - @OptIn(FlowPreview::class) - private fun initSearchQuery() { - viewModelScope.launch { - _searchQuery - .debounce(SEARCH_DEBOUNCE_DELAY.milliseconds) - .distinctUntilChanged() - .filter { it.isNotBlank() } - .collect { query -> executeSearch(query) } + private suspend fun loadSharingCapabilities() { + val result = repository.fetchSharingCapabilities() + if (result is NetworkResult.Success) { + _permissionPresets.update { + result.data.permissionPresets + } } } + private suspend fun restoreActiveShare() { + if (_activeShare.value != ActiveShareState.None) return + val restored = savedState.activeShareId?.let { fetchRestorableShare(it) } ?: return + + updateActiveShare(ActiveShareState.Editing(restored)) + replaceInList(restored) + } + + private suspend fun fetchRestorableShare(id: String): Share? { + val result = repository.fetchShare(id) + if (result !is NetworkResult.Success) return null + return result.data.takeIf { savedState.activeShareId == id } + } + + // region search query fun onSearchQueryChanged(query: String) { - _searchQuery.value = query + savedState.updateSearchQuery(query) } - private suspend fun executeSearch(query: String) { - val result = repository.fetchRecipients(null, query, 10, 0) - if (result is NetworkResult.Success) { - _recipientSearchResults.value = result.data - } + private suspend fun searchRecipients(query: String): List { + val result = repository.fetchRecipients(null, query, RECIPIENT_SEARCH_LIMIT, RECIPIENT_SEARCH_OFFSET) + return (result as? NetworkResult.Success)?.data ?: emptyList() } // endregion @@ -127,19 +153,28 @@ class ShareViewModel( filterSourceTypeClass: String? = null, filterSourceTypeValue: String? = null, lastShareID: String? = null, - limit: Int = 50 + limit: Int = SHARES_PAGE_SIZE ) { viewModelScope.launch(Dispatchers.IO) { - _state.update { ShareScreenState.Loading } - _errorMessageId.update { null } + loadShares(filterSourceTypeClass, filterSourceTypeValue, lastShareID, limit) + } + } - val result = repository.fetchShares(filterSourceTypeClass, filterSourceTypeValue, lastShareID, limit) - 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) - } + private suspend fun loadShares( + filterSourceTypeClass: String? = null, + filterSourceTypeValue: String? = null, + lastShareID: String? = null, + limit: Int = SHARES_PAGE_SIZE + ) { + _state.update { ShareScreenState.Loading } + _errorMessageId.update { null } + + val result = repository.fetchShares(filterSourceTypeClass, filterSourceTypeValue, lastShareID, limit) + val fetched = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } + ?: return + _state.update { + if (fetched.filtered().isEmpty()) ShareScreenState.Empty + else ShareScreenState.Loaded(fetched) } } @@ -151,26 +186,27 @@ class ShareViewModel( val share = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } ?: return@launch - _activeShare.update { share.toActiveShare() } + updateActiveShare(share.toActiveShare()) replaceInList(share) } } // endregion // region create - suspend fun createDraftShare(): Share? = withContext(Dispatchers.IO) { - _errorMessageId.update { null } - - val result = repository.createDraftShare() + fun createDraftShare(sourceValue: String) { + viewModelScope.launch(Dispatchers.IO) { + _errorMessageId.update { null } - val draft = result.dataOrElse { _errorMessageId.update { R.string.share_view_create_error_message } } + val result = repository.createDraftShare() + val draft = result.dataOrElse { _errorMessageId.update { R.string.share_view_create_error_message } } + ?: return@launch - if (draft != null) { - _activeShare.update { draft.toActiveShare() } + updateEditorEntry(ShareEditorEntry.EDIT) + updateActiveShare(draft.toActiveShare()) _state.update { ShareScreenState.Loaded(listOf(draft) + currentShares) } - } - draft + applySource(draft.id, sourceValue) + } } // endregion @@ -182,12 +218,12 @@ class ShareViewModel( ?: return@launch if (shareState == ShareState.ACTIVE) { if (updateAndDontDismiss) { - _activeShare.update { ActiveShareState.Activating(updated) } + refreshActiveShare(ActiveShareState.Activating(updated)) } else { - _activeShare.update { ActiveShareState.None } + updateActiveShare(ActiveShareState.None) } } else { - _activeShare.update { updated.toActiveShare() } + refreshActiveShare(updated.toActiveShare()) } replaceInList(updated) } @@ -197,22 +233,26 @@ 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 } } - ?: return@launch - _activeShare.update { updated.toActiveShare() } - replaceInList(updated) + applySource(id, value) } } + private suspend fun applySource(id: String, value: String) { + // 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 } } + ?: return + refreshActiveShare(updated.toActiveShare()) + replaceInList(updated) + } + fun removeSource(id: String, clazz: String, value: String) { viewModelScope.launch(Dispatchers.IO) { 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.toActiveShare() } + refreshActiveShare(updated.toActiveShare()) replaceInList(updated) } } @@ -224,7 +264,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.toActiveShare() } + refreshActiveShare(updated.toActiveShare()) replaceInList(updated) } } @@ -260,7 +300,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.toActiveShare() } + refreshActiveShare(updated.toActiveShare()) replaceInList(updated) } } @@ -277,7 +317,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.toActiveShare() } + refreshActiveShare(updated.toActiveShare()) replaceInList(updated) } } @@ -300,7 +340,7 @@ class ShareViewModel( when (val result = repository.updateShareProperty(shareId, UpdateSharePropertyRequest(clazz, value))) { is NetworkResult.Success -> { _propertyErrors.update { it - clazz } - _activeShare.update { result.data.toActiveShare() } + refreshActiveShare(result.data.toActiveShare()) replaceInList(result.data) } @@ -337,7 +377,7 @@ class ShareViewModel( val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch if (updateActiveShare) { - _activeShare.update { updated.toActiveShare() } + refreshActiveShare(updated.toActiveShare()) } replaceInList(updated) } @@ -358,7 +398,7 @@ class ShareViewModel( val editingShare = _activeShare.value if (editingShare is ActiveShareState.Editing && editingShare.share.id == id) { - _activeShare.update { ActiveShareState.None } + updateActiveShare(ActiveShareState.None) } } } @@ -369,14 +409,31 @@ class ShareViewModel( _errorMessageId.update { value } } - fun setActiveShare(value: Share?) { + fun setActiveShare(value: Share?, entry: ShareEditorEntry = ShareEditorEntry.EDIT) { _propertyErrors.update { emptyMap() } _pendingProperties.update { emptySet() } - _activeShare.update { value?.toActiveShare() ?: ActiveShareState.None } + updateEditorEntry(entry) + updateActiveShare(value?.toActiveShare() ?: ActiveShareState.None) } // endregion // region private + private fun updateEditorEntry(entry: ShareEditorEntry) { + _editorEntry.value = entry + savedState.editorEntry = entry + } + + private fun updateActiveShare(value: ActiveShareState) { + _activeShare.value = value + savedState.activeShareId = value.shareOrNull?.id + } + + private fun refreshActiveShare(value: ActiveShareState) { + val id = value.shareOrNull?.id ?: return + if (_activeShare.value.shareOrNull?.id != id) return + updateActiveShare(value) + } + 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/ShareViewModelFactory.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModelFactory.kt new file mode 100644 index 00000000..aa737406 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModelFactory.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.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras +import com.nextcloud.android.common.ui.share.repository.ShareRepository + +class ShareViewModelFactory( + private val repositoryProvider: () -> ShareRepository +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: Class, + extras: CreationExtras + ): T { + require(modelClass.isAssignableFrom(ShareViewModel::class.java)) { + "ShareViewModelFactory cannot create ${modelClass.name}" + } + + val savedStateHandle: SavedStateHandle = extras.createSavedStateHandle() + return ShareViewModel(repositoryProvider(), savedStateHandle) as T + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt index 8479e13f..7031cdbd 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt @@ -24,16 +24,16 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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 androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.svg.SvgDecoder @@ -96,9 +96,9 @@ private fun SelectedRecipient(recipient: Recipient, onClear: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RecipientSearch(share: Share, viewModel: ShareViewModel) { - var query by remember { mutableStateOf("") } - var expanded by remember { mutableStateOf(false) } - val results by viewModel.recipientSearchResults.collectAsState() + val query by viewModel.searchQuery.collectAsStateWithLifecycle() + var expanded by rememberSaveable { mutableStateOf(false) } + val results by viewModel.recipientSearchResults.collectAsStateWithLifecycle() ExposedDropdownMenuBox( modifier = Modifier @@ -110,7 +110,6 @@ private fun RecipientSearch(share: Share, viewModel: ShareViewModel) { OutlinedTextField( value = query, onValueChange = { - query = it expanded = true viewModel.onSearchQueryChanged(it) }, @@ -130,7 +129,7 @@ private fun RecipientSearch(share: Share, viewModel: ShareViewModel) { results = results, onSelect = { recipient -> viewModel.addRecipient(share.id, recipient.clazz, recipient.value) - query = "" + viewModel.onSearchQueryChanged("") 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 bea931a0..5b9c8863 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 @@ -35,12 +35,12 @@ 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 import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard @@ -49,6 +49,8 @@ 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 androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel import com.nextcloud.android.common.ui.share.component.CollapsibleShareSection @@ -84,17 +86,17 @@ fun AddOrEditShareBottomSheet( ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val categories = remember { ShareCategory.entries.toList() } - var selectedCategory by remember(share.id) { + var selectedCategory by rememberSaveable(share.id) { mutableStateOf(if (share.belongsAnyoneTab) ShareCategory.Anyone else ShareCategory.Invited) } val initialPresetOption = when (entry) { ShareEditorEntry.CUSTOMIZE_PERMISSION -> PermissionPresetOption.Custom else -> null } - var showAdvancedSettings by remember(share.id) { mutableStateOf(entry == ShareEditorEntry.SEND_EMAIL) } + var showAdvancedSettings by rememberSaveable(share.id) { mutableStateOf(entry == ShareEditorEntry.SEND_EMAIL) } val context = LocalContext.current - val propertyErrors by viewModel.propertyErrors.collectAsState() - val pendingProperties by viewModel.pendingProperties.collectAsState() + val propertyErrors by viewModel.propertyErrors.collectAsStateWithLifecycle() + val pendingProperties by viewModel.pendingProperties.collectAsStateWithLifecycle() val hasPropertyErrors = propertyErrors.isNotEmpty() val sendEnabled = !hasPropertyErrors && pendingProperties.isEmpty() @@ -218,7 +220,7 @@ private fun PermissionsView( permissionPresets: List, viewModel: ShareViewModel ) { - var selectedPreset by remember(share.id) { + var selectedPreset by rememberSaveable(share.id) { val default = share.getDefaultPermissionPresetOption(permissionPresets).presetClass mutableStateOf(initialPresetOption?.presetClass ?: share.permissionPreset ?: default) } @@ -458,7 +460,7 @@ private fun AddOrEditShareBottomSheetPreview() { AddOrEditShareBottomSheet( share = previewShare, internalLink = "internal_link", - viewModel = ShareViewModel(MockShareRepository()) + viewModel = ShareViewModel(MockShareRepository(), SavedStateHandle()) ) } } 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 c181e5d4..5d7347c9 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 @@ -14,7 +14,6 @@ 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 @@ -25,6 +24,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.ShareViewModel import com.nextcloud.android.common.ui.share.component.ShareSwitch @@ -38,7 +38,7 @@ 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 propertyErrors by viewModel.propertyErrors.collectAsStateWithLifecycle() val fallbackError = stringResource(R.string.share_view_update_error_message) val errorMessage = if (propertyErrors.containsKey(property.clazz)) { propertyErrors[property.clazz] ?: fallbackError 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 0b47eff0..b5c73b97 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 @@ -7,9 +7,9 @@ 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() +enum class ShareItemOverlayState { + None, + ContextMenu, + QuickShare, + DeleteConfirmation } From 9eba3725ec51b848c32f9fe2c5303abeb6f049af Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 11:20:16 +0200 Subject: [PATCH 118/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 132 +++++++++++------- .../android/common/ui/share/ShareViewModel.kt | 61 ++------ .../common/ui/share/ShareViewModelFactory.kt | 3 +- .../bottomsheet/AddOrEditShareBottomSheet.kt | 2 +- .../share/repository/MockShareRepository.kt | 17 +-- .../share/repository/ShareRemoteRepository.kt | 8 +- .../ui/share/repository/ShareRepository.kt | 6 +- 7 files changed, 112 insertions(+), 117 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 58f09f27..2671a34f 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.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -33,6 +34,7 @@ import androidx.compose.material3.CircularProgressIndicator 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 @@ -44,6 +46,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -92,9 +95,11 @@ import com.nextcloud.android.common.ui.share.model.ui.filtered import com.nextcloud.android.common.ui.share.model.ui.label import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository +private val FIRST_ITEM_TOP_SPACING = 16.dp +private val ITEM_SPACING = 2.dp + @Composable private fun ShareScreen( - sourceId: String, internalLink: String, viewModel: ShareViewModel ) { @@ -105,7 +110,6 @@ private fun ShareScreen( val editorEntry by viewModel.editorEntry.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val resources = LocalResources.current - val context = LocalContext.current LaunchedEffect(errorMessageId) { errorMessageId?.let { @@ -117,7 +121,7 @@ private fun ShareScreen( Scaffold( floatingActionButton = { FloatingActionButton( - onClick = { viewModel.createDraftShare(sourceId) }, + onClick = { viewModel.createDraftShare() }, ) { Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") } @@ -137,53 +141,19 @@ private fun ShareScreen( } } - 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 - ) { - val shares = state.shares.filtered() - itemsIndexed(shares, key = { _, share -> share.id }) { index, share -> - val type = ShareItemType.type(index, shares.lastIndex) - val title = share.getHeadline(context, shares) - - if (index == 0) { - Spacer(modifier = Modifier.height(16.dp)) - } else { - Spacer(modifier = Modifier.height(2.dp)) - } + is ShareScreenState.Empty -> ShareList( + shares = emptyList(), + permissionPresets = permissionPresets, + paddingValues = paddingValues, + viewModel = viewModel + ) - ShareItem( - share = share, - title = title, - type = type, - permissionPresets = permissionPresets, - onSelectShare = { selected -> - viewModel.setActiveShare(selected, ShareEditorEntry.EDIT) - }, - onCustomizeShare = { selected -> - viewModel.setActiveShare(selected, ShareEditorEntry.CUSTOMIZE_PERMISSION) - }, - onChangePreset = { selected, preset -> - viewModel.updatePermissionPreset(selected.id, preset, updateActiveShare = false) - }, - onDeleteShare = { viewModel.deleteShare(it.id) }, - onSendEmail = { selected -> - viewModel.setActiveShare(selected, ShareEditorEntry.SEND_EMAIL) - } - ) - } - } - } + is ShareScreenState.Loaded -> ShareList( + shares = state.shares.filtered(), + permissionPresets = permissionPresets, + paddingValues = paddingValues, + viewModel = viewModel + ) } } @@ -202,6 +172,66 @@ private fun ShareScreen( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShareList( + shares: List, + permissionPresets: List, + paddingValues: PaddingValues, + viewModel: ShareViewModel +) { + val context = LocalContext.current + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = viewModel::refreshShares, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (shares.isEmpty()) { + item { + Box(modifier = Modifier.fillParentMaxSize()) { + ContentUnavailableView( + iconId = R.drawable.ic_person_add, + title = stringResource(R.string.share_view_empty_title), + ) + } + } + } + + itemsIndexed(shares, key = { _, share -> share.id }) { index, share -> + Spacer(modifier = Modifier.height(if (index == 0) FIRST_ITEM_TOP_SPACING else ITEM_SPACING)) + + ShareItem( + share = share, + title = share.getHeadline(context, shares), + type = ShareItemType.type(index, shares.lastIndex), + permissionPresets = permissionPresets, + onSelectShare = { selected -> + viewModel.setActiveShare(selected, ShareEditorEntry.EDIT) + }, + onCustomizeShare = { selected -> + viewModel.setActiveShare(selected, ShareEditorEntry.CUSTOMIZE_PERMISSION) + }, + onChangePreset = { selected, preset -> + viewModel.updatePermissionPreset(selected.id, preset, updateActiveShare = false) + }, + onDeleteShare = { viewModel.deleteShare(it.id) }, + onSendEmail = { selected -> + viewModel.setActiveShare(selected, ShareEditorEntry.SEND_EMAIL) + } + ) + } + } + } +} + @Composable private fun ShareItem( share: Share, @@ -396,7 +426,7 @@ fun ComposeView.initShareScreen( credentials: ServerCredentials, colorScheme: ColorScheme ) { - val factory = ShareViewModelFactory { + val factory = ShareViewModelFactory(sourceId) { ShareRemoteRepository(NextcloudHttpClient.create(credentials)) } val viewModel = ViewModelProvider.create(viewModelStoreOwner, factory)[ShareViewModel::class] @@ -411,7 +441,7 @@ fun ComposeView.initShareScreen( MaterialTheme( colorScheme = colorScheme, content = { - ShareScreen(sourceId, internalLink, viewModel) + ShareScreen(internalLink, 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 d83337e8..427bc1e5 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 @@ -17,7 +17,6 @@ import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPres 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 @@ -51,6 +50,7 @@ import kotlin.time.Duration.Companion.milliseconds class ShareViewModel( private val repository: ShareRepository, + private val sourceId: String, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -67,6 +67,9 @@ class ShareViewModel( private val _state = MutableStateFlow(ShareScreenState.Loading) val state: StateFlow = _state + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing + private val _activeShare = MutableStateFlow(ActiveShareState.None) val activeShare: StateFlow = _activeShare @@ -149,27 +152,20 @@ class ShareViewModel( // endregion // region shares list - fun fetchShares( - filterSourceTypeClass: String? = null, - filterSourceTypeValue: String? = null, - lastShareID: String? = null, - limit: Int = SHARES_PAGE_SIZE - ) { + fun refreshShares() { + if (_isRefreshing.value) return + viewModelScope.launch(Dispatchers.IO) { - loadShares(filterSourceTypeClass, filterSourceTypeValue, lastShareID, limit) + _isRefreshing.update { true } + loadShares() + _isRefreshing.update { false } } } - private suspend fun loadShares( - filterSourceTypeClass: String? = null, - filterSourceTypeValue: String? = null, - lastShareID: String? = null, - limit: Int = SHARES_PAGE_SIZE - ) { - _state.update { ShareScreenState.Loading } + private suspend fun loadShares() { _errorMessageId.update { null } - val result = repository.fetchShares(filterSourceTypeClass, filterSourceTypeValue, lastShareID, limit) + val result = repository.fetchShares(filterSourceTypeValue = sourceId, limit = SHARES_PAGE_SIZE) val fetched = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } ?: return _state.update { @@ -177,23 +173,10 @@ class ShareViewModel( else ShareScreenState.Loaded(fetched) } } - - fun fetchShare(id: String, request: GetShareRequest = GetShareRequest()) { - viewModelScope.launch(Dispatchers.IO) { - _errorMessageId.update { null } - - val result = repository.fetchShare(id, request) - val share = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } - ?: return@launch - - updateActiveShare(share.toActiveShare()) - replaceInList(share) - } - } // endregion // region create - fun createDraftShare(sourceValue: String) { + fun createDraftShare() { viewModelScope.launch(Dispatchers.IO) { _errorMessageId.update { null } @@ -205,7 +188,7 @@ class ShareViewModel( updateActiveShare(draft.toActiveShare()) _state.update { ShareScreenState.Loaded(listOf(draft) + currentShares) } - applySource(draft.id, sourceValue) + applySource(draft.id, sourceId) } } // endregion @@ -231,12 +214,6 @@ class ShareViewModel( // endregion // region sources - fun addSource(id: String, value: String) { - viewModelScope.launch(Dispatchers.IO) { - applySource(id, value) - } - } - private suspend fun applySource(id: String, value: String) { // 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" @@ -246,16 +223,6 @@ class ShareViewModel( refreshActiveShare(updated.toActiveShare()) replaceInList(updated) } - - fun removeSource(id: String, clazz: String, value: String) { - viewModelScope.launch(Dispatchers.IO) { - val result = repository.removeShareSource(id, clazz, value) - val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } - ?: return@launch - refreshActiveShare(updated.toActiveShare()) - replaceInList(updated) - } - } // endregion // region recipients diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModelFactory.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModelFactory.kt index aa737406..b6fa9307 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModelFactory.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModelFactory.kt @@ -15,6 +15,7 @@ import androidx.lifecycle.viewmodel.CreationExtras import com.nextcloud.android.common.ui.share.repository.ShareRepository class ShareViewModelFactory( + private val sourceId: String, private val repositoryProvider: () -> ShareRepository ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -27,6 +28,6 @@ class ShareViewModelFactory( } val savedStateHandle: SavedStateHandle = extras.createSavedStateHandle() - return ShareViewModel(repositoryProvider(), savedStateHandle) as T + return ShareViewModel(repositoryProvider(), sourceId, savedStateHandle) as T } } 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 5b9c8863..77e123a7 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 @@ -460,7 +460,7 @@ private fun AddOrEditShareBottomSheetPreview() { AddOrEditShareBottomSheet( share = previewShare, internalLink = "internal_link", - viewModel = ShareViewModel(MockShareRepository(), SavedStateHandle()) + viewModel = ShareViewModel(MockShareRepository(), "preview-source", SavedStateHandle()) ) } } 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 25876385..1a2bb62d 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 @@ -324,18 +324,15 @@ class MockShareRepository : ShareRepository { } override suspend fun fetchShares( + filterSourceTypeValue: String, + limit: Int, filterSourceTypeClass: String?, - filterSourceTypeValue: String?, - lastShareID: String?, - limit: Int + lastShareID: String? ): NetworkResult> { - var result = mockShares.toList() - - if (filterSourceTypeClass != null) { - result = result.filter { share -> - share.sources.any { - it.clazz == filterSourceTypeClass && (filterSourceTypeValue == null || it.value == filterSourceTypeValue) - } + var result = mockShares.filter { share -> + share.sources.any { + it.value == filterSourceTypeValue && + (filterSourceTypeClass == null || it.clazz == filterSourceTypeClass) } } 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 a454e806..0e20df77 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 @@ -92,15 +92,15 @@ class ShareRemoteRepository( ) { } override suspend fun fetchShares( + filterSourceTypeValue: String, + limit: Int, filterSourceTypeClass: String?, - filterSourceTypeValue: String?, - lastShareID: String?, - limit: Int + lastShareID: String? ): NetworkResult> { val queryParams = buildString { append("?limit=$limit") + append("&filterSourceTypeValue=${filterSourceTypeValue.urlEncoded()}") filterSourceTypeClass?.let { append("&filterSourceTypeClass=${it.urlEncoded()}") } - filterSourceTypeValue?.let { append("&filterSourceTypeValue=${it.urlEncoded()}") } lastShareID?.let { append("&lastShareID=${it.urlEncoded()}") } } return client.executeRequest( 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 118311e7..6f5cae0d 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 @@ -38,10 +38,10 @@ interface ShareRepository { suspend fun deleteShare(id: String): NetworkResult suspend fun fetchShares( + filterSourceTypeValue: String, + limit: Int, filterSourceTypeClass: String? = null, - filterSourceTypeValue: String? = null, - lastShareID: String? = null, - limit: Int + lastShareID: String? = null ): NetworkResult> suspend fun updateShareState( From 48e7485a8a75a77ca6bddb83fbcd4fe57738ecd3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 11:51:10 +0200 Subject: [PATCH 119/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 57 ++++++++--- .../share/component/SelectRecipientField.kt | 98 +++++++++++-------- .../bottomsheet/AddOrEditShareBottomSheet.kt | 55 ++++++----- .../property/SharePropertyEnumField.kt | 5 +- .../property/SharePropertyMessage.kt | 35 +++++++ .../component/property/SharePropertyView.kt | 78 ++++++++------- .../property/datepicker/ShareDatePicker.kt | 7 +- .../share/model/api/recipients/Recipient.kt | 3 + .../common/ui/share/model/api/share/Share.kt | 39 +++++--- .../ui/share/model/api/source/Source.kt | 6 +- 10 files changed, 248 insertions(+), 135 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyMessage.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 427bc1e5..7dc1d645 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 @@ -23,6 +23,7 @@ import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareProper 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.source.Source 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 @@ -70,6 +71,12 @@ class ShareViewModel( private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing + private val _isPreparingLink = MutableStateFlow(false) + val isPreparingLink: StateFlow = _isPreparingLink + + private val _shareReadyToCopy = MutableStateFlow(null) + val shareReadyToCopy: StateFlow = _shareReadyToCopy + private val _activeShare = MutableStateFlow(ActiveShareState.None) val activeShare: StateFlow = _activeShare @@ -165,7 +172,11 @@ class ShareViewModel( private suspend fun loadShares() { _errorMessageId.update { null } - val result = repository.fetchShares(filterSourceTypeValue = sourceId, limit = SHARES_PAGE_SIZE) + val result = repository.fetchShares( + filterSourceTypeValue = sourceId, + limit = SHARES_PAGE_SIZE, + filterSourceTypeClass = Source.NODE_SOURCE_CLASS + ) val fetched = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } ?: return _state.update { @@ -194,30 +205,49 @@ class ShareViewModel( // endregion // region state - fun updateState(id: String, shareState: ShareState, updateAndDontDismiss: Boolean = false) { + fun updateState(id: String, shareState: 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 + val updated = applyState(id, shareState) ?: return@launch + if (shareState == ShareState.ACTIVE) { - if (updateAndDontDismiss) { - refreshActiveShare(ActiveShareState.Activating(updated)) - } else { - updateActiveShare(ActiveShareState.None) - } + updateActiveShare(ActiveShareState.None) } else { refreshActiveShare(updated.toActiveShare()) } replaceInList(updated) } } + + fun prepareLinkForCopy(id: String) { + if (_isPreparingLink.value) return + + viewModelScope.launch(Dispatchers.IO) { + _isPreparingLink.update { true } + + val updated = applyState(id, ShareState.ACTIVE) + if (updated != null) { + refreshActiveShare(ActiveShareState.Activating(updated)) + replaceInList(updated) + _shareReadyToCopy.update { updated } + } + + _isPreparingLink.update { false } + } + } + + fun onLinkCopied() { + _shareReadyToCopy.update { null } + } + + private suspend fun applyState(id: String, shareState: ShareState): Share? { + val result = repository.updateShareState(id, UpdateShareStateRequest(shareState)) + return result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + } // endregion // region sources private suspend fun applySource(id: String, value: String) { - // 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 result = repository.addShareSource(id, AddSourceRequest(Source.NODE_SOURCE_CLASS, value)) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return refreshActiveShare(updated.toActiveShare()) @@ -379,6 +409,7 @@ class ShareViewModel( fun setActiveShare(value: Share?, entry: ShareEditorEntry = ShareEditorEntry.EDIT) { _propertyErrors.update { emptyMap() } _pendingProperties.update { emptySet() } + _shareReadyToCopy.update { null } updateEditorEntry(entry) updateActiveShare(value?.toActiveShare() ?: ActiveShareState.None) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt index 7031cdbd..4d1a0f3b 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt @@ -8,7 +8,11 @@ package com.nextcloud.android.common.ui.share.component 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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -19,7 +23,7 @@ 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 @@ -43,67 +47,79 @@ 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 +private val FIELD_PADDING = 12.dp +private val CHIP_SPACING = 8.dp +private val CHIP_AVATAR_SIZE = 20.dp +private val CHIP_CLOSE_ICON_SIZE = 18.dp +private val RESULT_ICON_SIZE = 20.dp + @Composable fun SelectRecipientField( share: Share, viewModel: ShareViewModel ) { - val selectedRecipient = share.recipients.firstOrNull { it.clazz != Recipient.TOKEN_RECIPIENT_CLASS } + val selected = share.invitedRecipients - if (selectedRecipient == null) { - RecipientSearch(share, viewModel) - return - } + Column(modifier = Modifier.fillMaxWidth()) { + RecipientChips( + recipients = selected, + onRemove = { recipient -> + viewModel.removeRecipient( + id = share.id, + clazz = recipient.clazz, + value = recipient.value, + instance = recipient.instance + ) + } + ) - SelectedRecipient( - recipient = selectedRecipient, - onClear = { - viewModel.removeRecipient( - id = share.id, - clazz = selectedRecipient.clazz, - value = selectedRecipient.value, - instance = selectedRecipient.instance - ) - } - ) + RecipientSearch(share = share, selected = selected, viewModel = viewModel) + } } +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun SelectedRecipient(recipient: Recipient, onClear: () -> Unit) { - OutlinedTextField( - value = recipient.displayName, - onValueChange = {}, - readOnly = true, - singleLine = true, +private fun RecipientChips(recipients: List, onRemove: (Recipient) -> Unit) { + if (recipients.isEmpty()) return + + FlowRow( modifier = Modifier .fillMaxWidth() - .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) - ) - } + .padding(horizontal = FIELD_PADDING), + horizontalArrangement = Arrangement.spacedBy(CHIP_SPACING) + ) { + recipients.forEach { recipient -> + InputChip( + selected = false, + onClick = { onRemove(recipient) }, + label = { Text(recipient.displayName) }, + avatar = { + recipient.icon?.let { RecipientIcon(icon = it, modifier = Modifier.size(CHIP_AVATAR_SIZE)) } + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.share_view_select_recipient_clear), + modifier = Modifier.size(CHIP_CLOSE_ICON_SIZE) + ) + } + ) } - ) + } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun RecipientSearch(share: Share, viewModel: ShareViewModel) { +private fun RecipientSearch(share: Share, selected: List, viewModel: ShareViewModel) { val query by viewModel.searchQuery.collectAsStateWithLifecycle() var expanded by rememberSaveable { mutableStateOf(false) } val results by viewModel.recipientSearchResults.collectAsStateWithLifecycle() + val suggestions = results.filterNot { result -> selected.any { it.isSameAs(result) } } ExposedDropdownMenuBox( modifier = Modifier .fillMaxWidth() - .padding(12.dp), + .padding(FIELD_PADDING), expanded = expanded && query.isNotBlank(), onExpandedChange = { expanded = it } ) { @@ -126,9 +142,9 @@ private fun RecipientSearch(share: Share, viewModel: ShareViewModel) { onDismissRequest = { expanded = false } ) { RecipientDropdownContent( - results = results, + results = suggestions, onSelect = { recipient -> - viewModel.addRecipient(share.id, recipient.clazz, recipient.value) + viewModel.addRecipient(share.id, recipient.clazz, recipient.value, recipient.instance) viewModel.onSearchQueryChanged("") expanded = false } @@ -162,7 +178,7 @@ private fun RecipientDropdownContent( recipient.icon?.let { RecipientIcon( icon = it, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(RESULT_ICON_SIZE) ) } }, 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 77e123a7..4e7019c3 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 @@ -20,6 +20,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType @@ -35,6 +36,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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -361,35 +363,34 @@ private fun ActionButtons( sendEnabled: Boolean, viewModel: ShareViewModel ) { + val localClipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val isPreparingLink by viewModel.isPreparingLink.collectAsStateWithLifecycle() + val shareReadyToCopy by viewModel.shareReadyToCopy.collectAsStateWithLifecycle() + + LaunchedEffect(shareReadyToCopy) { + val ready = shareReadyToCopy ?: return@LaunchedEffect + ready.getClipEntry(internalLink, category)?.let { localClipboard.setClipEntry(it) } + viewModel.onLinkCopied() + } + Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - val localClipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - Button( onClick = { - scope.launch { - share.getClipEntry(internalLink, category)?.let { - 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) - } + if (category == ShareCategory.Anyone) { + viewModel.prepareLinkForCopy(share.id) + } else { + scope.launch { + share.getClipEntry(internalLink, category)?.let { localClipboard.setClipEntry(it) } } } }, - enabled = category != ShareCategory.Anyone || sendEnabled, + enabled = !isPreparingLink && (category != ShareCategory.Anyone || sendEnabled), modifier = Modifier.weight(1f), shape = RoundedCornerShape(10.dp), colors = ButtonDefaults.buttonColors( @@ -397,11 +398,19 @@ private fun ActionButtons( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { - Icon( - painter = painterResource(R.drawable.ic_link), - contentDescription = null, - modifier = Modifier.size(20.dp), - ) + if (isPreparingLink) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurface + ) + } else { + 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), 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 index 301bd373..22fb837d 100644 --- 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 @@ -29,7 +29,7 @@ import com.nextcloud.android.common.ui.share.model.api.property.PropertyEnum @Composable fun SharePropertyEnumField( property: PropertyEnum, - errorMessage: String?, + isError: Boolean, onValueSelected: (String) -> Unit ) { var expanded by remember { mutableStateOf(false) } @@ -49,8 +49,7 @@ fun SharePropertyEnumField( singleLine = true, label = { Text(property.displayName) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - isError = errorMessage != null, - supportingText = errorMessage?.let { { Text(it) } }, + isError = isError, modifier = Modifier .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, true) .fillMaxWidth() diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyMessage.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyMessage.kt new file mode 100644 index 00000000..77363da8 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyMessage.kt @@ -0,0 +1,35 @@ +/* + * 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.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +private val MESSAGE_START_PADDING = 16.dp +private val MESSAGE_BOTTOM_PADDING = 4.dp + +@Composable +fun SharePropertyMessage(errorMessage: String?, hint: String?) { + val message = errorMessage ?: hint?.takeIf { it.isNotBlank() } ?: return + val color = if (errorMessage != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Text( + text = message, + color = color, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = MESSAGE_START_PADDING, bottom = MESSAGE_BOTTOM_PADDING) + ) +} 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 5d7347c9..fcf0fcd9 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 @@ -10,7 +10,6 @@ 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 @@ -46,28 +45,37 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM null } + Column { + SharePropertyField( + shareId = shareId, + property = property, + isError = errorMessage != null, + viewModel = viewModel + ) + + SharePropertyMessage(errorMessage = errorMessage, hint = property.hint) + } +} + +@Composable +private fun SharePropertyField( + shareId: String, + property: Property, + isError: Boolean, + viewModel: ShareViewModel +) { when (property) { is PropertyBoolean -> { var checkedValue by remember(property.clazz) { mutableStateOf(property.isTrue()) } - Column { - ShareSwitch( - label = property.displayName, - checked = checkedValue, - onCheckedChange = { isChecked -> - checkedValue = isChecked - viewModel.updateProperty(shareId, property.clazz, isChecked.toString()) - } - ) - if (errorMessage != null) { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 4.dp, bottom = 4.dp) - ) + ShareSwitch( + label = property.displayName, + checked = checkedValue, + onCheckedChange = { isChecked -> + checkedValue = isChecked + viewModel.updateProperty(shareId, property.clazz, isChecked.toString()) } - } + ) } is PropertyString -> ShareTextPropertyField( @@ -75,7 +83,7 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM clazz = property.clazz, displayName = property.displayName, initialValue = property.value ?: "", - errorMessage = errorMessage, + isError = isError, viewModel = viewModel ) @@ -84,25 +92,22 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM clazz = property.clazz, displayName = property.displayName, initialValue = property.value ?: "", - errorMessage = errorMessage, + isError = isError, viewModel = viewModel, - hint = property.hint, visualTransformation = PasswordVisualTransformation() ) - is PropertyDate -> { - ShareDatePicker(property, errorMessage = errorMessage, onDateSelected = { dateValue -> - viewModel.updateProperty(shareId, property.clazz, dateValue) - }) - } + is PropertyDate -> ShareDatePicker( + property = property, + isError = isError, + onDateSelected = { dateValue -> viewModel.updateProperty(shareId, property.clazz, dateValue) } + ) - is PropertyEnum -> { - SharePropertyEnumField( - property = property, - errorMessage = errorMessage, - onValueSelected = { value -> viewModel.updateProperty(shareId, property.clazz, value) } - ) - } + is PropertyEnum -> SharePropertyEnumField( + property = property, + isError = isError, + onValueSelected = { value -> viewModel.updateProperty(shareId, property.clazz, value) } + ) } } @@ -112,9 +117,8 @@ private fun ShareTextPropertyField( clazz: String, displayName: String, initialValue: String, - errorMessage: String?, + isError: Boolean, viewModel: ShareViewModel, - hint: String? = null, visualTransformation: VisualTransformation = VisualTransformation.None ) { var value by remember(clazz) { mutableStateOf(initialValue) } @@ -125,10 +129,8 @@ private fun ShareTextPropertyField( value = value, onValueChange = { value = it }, label = { Text(displayName) }, - placeholder = hint?.let { { Text(it) } }, visualTransformation = visualTransformation, - isError = errorMessage != null, - supportingText = errorMessage?.let { { Text(it) } }, + isError = isError, modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) 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 153e6f51..5bf623b3 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 @@ -33,7 +33,7 @@ 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, errorMessage: String?, onDateSelected: (String) -> Unit) { +fun ShareDatePicker(property: PropertyDate, isError: Boolean, onDateSelected: (String) -> Unit) { val formatter = remember { ShareDateFormatter() } var showDatePicker by remember { mutableStateOf(false) } var dateValue by remember(property.clazz) { mutableStateOf(formatter.formatIsoForDisplay(property.value)) } @@ -44,8 +44,7 @@ fun ShareDatePicker(property: PropertyDate, errorMessage: String?, onDateSelecte onValueChange = {}, readOnly = true, enabled = true, - isError = errorMessage != null, - supportingText = errorMessage?.let { { Text(it) } }, + isError = isError, modifier = Modifier.fillMaxWidth(), label = { Text(formatter.getDisplayName(property)) }, trailingIcon = { @@ -123,7 +122,7 @@ private fun ShareDatePickerPreview() { minDate = "2026-01-01", maxDate = "2027-12-31" ), - errorMessage = null, + isError = false, onDateSelected = {} ) } 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 d5f967d5..601dabcc 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 @@ -34,4 +34,7 @@ data class Recipient( companion object { const val TOKEN_RECIPIENT_CLASS = """OC\Core\Sharing\Recipient\TokenShareRecipientType""" } + + fun isSameAs(other: Recipient): Boolean = + clazz == other.clazz && value == other.value && instance == other.instance } 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 0f3a303d..d52a3e34 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,6 +48,10 @@ data class Share( @SerialName("permission_preset") val permissionPreset: String? = null ) { + companion object { + private const val LABEL_PROPERTY_CLASS = "label" + } + val basicProperties: List by lazy { properties.filterNot { it.advanced }.sortedBy { it.priority } } @@ -71,14 +75,12 @@ data class Share( } 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 - ClipData.newPlainText(recipient.displayName, link).toClipEntry() - } else { - ClipData.newPlainText(recipient.displayName, internalLink).toClipEntry() + if (category == ShareCategory.Anyone) { + val recipient = customLinkRecipient ?: return null + return recipient.secret.url?.let { ClipData.newPlainText(recipient.displayName, it).toClipEntry() } } + + return recipients.firstOrNull()?.let { ClipData.newPlainText(it.displayName, internalLink).toClipEntry() } } fun getHeadline(context: Context, shares: List): String { @@ -86,15 +88,25 @@ data class Share( return recipients.firstOrNull()?.displayName ?: "" } + return label ?: publicLinkHeadline(context, shares) + } + + private fun publicLinkHeadline(context: Context, shares: List): String { val publicLinks = shares.filter { it.belongsAnyoneTab } - return if (publicLinks.size <= 1) { - context.getString(R.string.share_view_public_link) - } else { - val position = publicLinks.sortedBy { it.lastUpdated }.indexOfFirst { it.id == id } + 1 - context.getString(R.string.share_view_public_link_numbered, position) + if (publicLinks.size <= 1) { + return context.getString(R.string.share_view_public_link) } + + val position = publicLinks.sortedBy { it.lastUpdated }.indexOfFirst { it.id == id } + 1 + return context.getString(R.string.share_view_public_link_numbered, position) } + val label: String? + get() = properties + .firstOrNull { it.clazz.contains(LABEL_PROPERTY_CLASS, ignoreCase = true) } + ?.value + ?.takeIf { it.isNotBlank() } + val belongsAnyoneTab: Boolean get() { return customLinkRecipient != null @@ -104,6 +116,9 @@ data class Share( recipients.firstOrNull { it.secret.updatable } } + val invitedRecipients: List + get() = recipients.filterNot { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS } + // The preset matching the enabled permissions dictated by the server admin: // only the read permission -> "Can view", every permission -> "Can edit", any other subset -> "Can ...". fun getDefaultPermissionPresetOption(permissionPresets: List): PermissionPresetOption { 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 0635a4bd..f4c66311 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 @@ -22,4 +22,8 @@ data class Source( val displayName: String, val icon: Icon? = null -) +) { + companion object { + const val NODE_SOURCE_CLASS = """OCA\Files\Sharing\Source\NodeShareSourceType""" + } +} From 5047ec12d4c870680b770a2dad44bbc95322bbc7 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 11:58:31 +0200 Subject: [PATCH 120/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 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 7dc1d645..1a56e9eb 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 @@ -60,7 +60,8 @@ class ShareViewModel( private const val SEARCH_SUBSCRIPTION_TIMEOUT = 5_000L private const val RECIPIENT_SEARCH_LIMIT = 10 private const val RECIPIENT_SEARCH_OFFSET = 0 - private const val SHARES_PAGE_SIZE = 50 + private const val SHARES_PAGE_SIZE = 100 + private const val MAX_SHARE_PAGES = 20 } private val savedState = ShareSavedState(savedStateHandle) @@ -172,18 +173,39 @@ class ShareViewModel( private suspend fun loadShares() { _errorMessageId.update { null } - val result = repository.fetchShares( - filterSourceTypeValue = sourceId, - limit = SHARES_PAGE_SIZE, - filterSourceTypeClass = Source.NODE_SOURCE_CLASS - ) - val fetched = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } - ?: return + val fetched = fetchAllShares() ?: return _state.update { if (fetched.filtered().isEmpty()) ShareScreenState.Empty else ShareScreenState.Loaded(fetched) } } + + // The server pages drafts and active shares together while the list only renders active ones, + // so a file with a page worth of abandoned drafts would otherwise hide every real share. + private suspend fun fetchAllShares(): List? { + val shares = mutableListOf() + var cursor: String? = null + var page = 0 + + while (page < MAX_SHARE_PAGES) { + val result = repository.fetchShares( + filterSourceTypeValue = sourceId, + limit = SHARES_PAGE_SIZE, + filterSourceTypeClass = Source.NODE_SOURCE_CLASS, + lastShareID = cursor + ) + val fetched = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } + ?: return null + + shares += fetched + if (fetched.size < SHARES_PAGE_SIZE) break + + cursor = fetched.last().id + page++ + } + + return shares + } // endregion // region create From 30665c2ddc1f51b0da48873e684a73127c07a65b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 15:53:56 +0200 Subject: [PATCH 121/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 3 +- .../android/common/ui/share/ShareViewModel.kt | 36 ++++++++++++------- .../ui/share/model/ui/ShareScreenState.kt | 2 +- 3 files changed, 25 insertions(+), 16 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 2671a34f..78d256ec 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 @@ -91,7 +91,6 @@ import com.nextcloud.android.common.ui.share.model.ui.ShareEditorEntry 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.model.ui.label import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository @@ -149,7 +148,7 @@ private fun ShareScreen( ) is ShareScreenState.Loaded -> ShareList( - shares = state.shares.filtered(), + shares = state.shares, permissionPresets = permissionPresets, paddingValues = paddingValues, viewModel = 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 1a56e9eb..8ecaaf03 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 @@ -29,7 +29,7 @@ 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.ShareEditorEntry 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.model.ui.activeOnly import com.nextcloud.android.common.ui.share.repository.ShareRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -174,10 +174,7 @@ class ShareViewModel( _errorMessageId.update { null } val fetched = fetchAllShares() ?: return - _state.update { - if (fetched.filtered().isEmpty()) ShareScreenState.Empty - else ShareScreenState.Loaded(fetched) - } + publishShares(fetched.activeOnly()) } // The server pages drafts and active shares together while the list only renders active ones, @@ -219,7 +216,6 @@ class ShareViewModel( updateEditorEntry(ShareEditorEntry.EDIT) updateActiveShare(draft.toActiveShare()) - _state.update { ShareScreenState.Loaded(listOf(draft) + currentShares) } applySource(draft.id, sourceId) } @@ -409,11 +405,7 @@ class ShareViewModel( val result = repository.deleteShare(id) result.dataOrElse { _errorMessageId.update { 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) - } + publishShares(currentShares.filterNot { it.id == id }) val editingShare = _activeShare.value if (editingShare is ActiveShareState.Editing && editingShare.share.id == id) { @@ -454,9 +446,27 @@ class ShareViewModel( updateActiveShare(value) } + // The list only ever holds active shares, so a draft being edited never reaches it and an + // activated one is inserted rather than silently dropped. private fun replaceInList(updated: Share) { - val shares = currentShares.ifEmpty { return } - _state.update { ShareScreenState.Loaded(shares.map { if (it.id == updated.id) updated else it }) } + val current = currentShares + val index = current.indexOfFirst { it.id == updated.id } + val isActive = updated.shareState == ShareState.ACTIVE + + val shares = when { + index >= 0 && isActive -> current.toMutableList().apply { this[index] = updated } + index >= 0 -> current.filterNot { it.id == updated.id } + isActive -> listOf(updated) + current + else -> return + } + + publishShares(shares) + } + + private fun publishShares(shares: List) { + _state.update { + if (shares.isEmpty()) ShareScreenState.Empty else ShareScreenState.Loaded(shares) + } } // endregion } 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 1b912e17..728af1a8 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 @@ -16,4 +16,4 @@ sealed class ShareScreenState { data class Loaded(val shares: List): ShareScreenState() } -fun List.filtered(): List = filter { it.shareState != ShareState.DRAFT } +fun List.activeOnly(): List = filter { it.shareState == ShareState.ACTIVE } From e974ae0f298970fc6f09d33d1835893a74ae7f7d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 16:04:54 +0200 Subject: [PATCH 122/144] wip Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 32 ++++-- .../common/ui/share/model/api/share/Share.kt | 16 --- .../ShareDefaultPermissionPresetOptionTest.kt | 102 ------------------ 3 files changed, 21 insertions(+), 129 deletions(-) delete mode 100644 ui/src/test/java/com/nextcloud/android/common/ui/share/model/api/share/ShareDefaultPermissionPresetOptionTest.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 4e7019c3..cd21d05d 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 @@ -76,6 +76,8 @@ import com.nextcloud.android.common.ui.share.model.ui.label import com.nextcloud.android.common.ui.share.repository.MockShareRepository import kotlinx.coroutines.launch +private const val CUSTOM_SELECTION = "custom" + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AddOrEditShareBottomSheet( @@ -91,10 +93,6 @@ fun AddOrEditShareBottomSheet( var selectedCategory by rememberSaveable(share.id) { mutableStateOf(if (share.belongsAnyoneTab) ShareCategory.Anyone else ShareCategory.Invited) } - val initialPresetOption = when (entry) { - ShareEditorEntry.CUSTOMIZE_PERMISSION -> PermissionPresetOption.Custom - else -> null - } var showAdvancedSettings by rememberSaveable(share.id) { mutableStateOf(entry == ShareEditorEntry.SEND_EMAIL) } val context = LocalContext.current val propertyErrors by viewModel.propertyErrors.collectAsStateWithLifecycle() @@ -145,7 +143,7 @@ fun AddOrEditShareBottomSheet( PermissionsView( share = share, - initialPresetOption = initialPresetOption, + entry = entry, permissionPresets = permissionPresets, viewModel = viewModel ) @@ -218,20 +216,30 @@ private fun ShareCategorySelector( @Composable private fun PermissionsView( share: Share, - initialPresetOption: PermissionPresetOption?, + entry: ShareEditorEntry, permissionPresets: List, viewModel: ShareViewModel ) { - var selectedPreset by rememberSaveable(share.id) { - val default = share.getDefaultPermissionPresetOption(permissionPresets).presetClass - mutableStateOf(initialPresetOption?.presetClass ?: share.permissionPreset ?: default) + // Null follows the preset the backend reports, which is only known once the draft has a source. + // Anything else is the user's explicit pick and wins from then on, so staying on "Can…" is sticky + // even when the toggles they flip happen to add up to a preset. + var userSelection by rememberSaveable(share.id) { + mutableStateOf( + if (entry == ShareEditorEntry.CUSTOMIZE_PERMISSION) CUSTOM_SELECTION else null + ) + } + + val selectedPreset = when (userSelection) { + null -> share.permissionPreset + CUSTOM_SELECTION -> null + else -> userSelection } PermissionPresetDropdown( options = PermissionPresetOption.optionsFor(share, permissionPresets), selectedOption = PermissionPresetOption.from(selectedPreset, permissionPresets), onOptionSelected = { option -> - selectedPreset = option.presetClass + userSelection = option.presetClass ?: CUSTOM_SELECTION option.presetClass?.let { viewModel.updatePermissionPreset(share.id, it, true) } } ) @@ -240,7 +248,9 @@ private fun PermissionsView( share.permissions.forEach { permission -> key(permission.clazz) { - var checked by remember { mutableStateOf(permission.enabled) } + var checked by remember(permission.clazz, permission.enabled) { + mutableStateOf(permission.enabled) + } ShareSwitch( label = permission.displayName, checked = checked, 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 d52a3e34..5d62adbb 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 @@ -13,14 +13,12 @@ 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.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 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.ShareCategory import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -119,20 +117,6 @@ data class Share( val invitedRecipients: List get() = recipients.filterNot { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS } - // The preset matching the enabled permissions dictated by the server admin: - // only the read permission -> "Can view", every permission -> "Can edit", any other subset -> "Can ...". - fun getDefaultPermissionPresetOption(permissionPresets: List): PermissionPresetOption { - val presetControlled = permissions.filter { it.presets.isNotEmpty() } - val enabledClasses = presetControlled.filter { it.enabled }.map { it.clazz }.toSet() - if (enabledClasses.isEmpty()) return PermissionPresetOption.Custom - - val matchingPreset = permissionPresets.firstOrNull { preset -> - presetControlled.filter { preset.clazz in it.presets }.map { it.clazz }.toSet() == enabledClasses - } - - return matchingPreset?.let { PermissionPresetOption.Preset(it) } ?: PermissionPresetOption.Custom - } - val canSend: Boolean get() { return sources.isNotEmpty() && diff --git a/ui/src/test/java/com/nextcloud/android/common/ui/share/model/api/share/ShareDefaultPermissionPresetOptionTest.kt b/ui/src/test/java/com/nextcloud/android/common/ui/share/model/api/share/ShareDefaultPermissionPresetOptionTest.kt deleted file mode 100644 index ec43d7a5..00000000 --- a/ui/src/test/java/com/nextcloud/android/common/ui/share/model/api/share/ShareDefaultPermissionPresetOptionTest.kt +++ /dev/null @@ -1,102 +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.share - -import com.nextcloud.android.common.ui.share.model.api.icon.Icon -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.state.ShareState -import com.nextcloud.android.common.ui.share.model.api.user.User -import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption -import org.junit.Assert.assertEquals -import org.junit.Test - -class ShareDefaultPermissionPresetOptionTest { - - @Test - fun `returns view preset when only read is enabled`() { - val share = shareWithEnabled(read = true, update = false, delete = false) - - val result = share.getDefaultPermissionPresetOption(PRESETS) - - assertEquals(PermissionPresetOption.Preset(VIEW_PRESET), result) - } - - @Test - fun `returns edit preset when all permissions are enabled`() { - val share = shareWithEnabled(read = true, update = true, delete = true) - - val result = share.getDefaultPermissionPresetOption(PRESETS) - - assertEquals(PermissionPresetOption.Preset(EDIT_PRESET), result) - } - - @Test - fun `returns custom when only some permissions are enabled`() { - val share = shareWithEnabled(read = true, update = true, delete = false) - - val result = share.getDefaultPermissionPresetOption(PRESETS) - - assertEquals(PermissionPresetOption.Custom, result) - } - - @Test - fun `returns custom when no permission is enabled`() { - val share = shareWithEnabled(read = false, update = false, delete = false) - - val result = share.getDefaultPermissionPresetOption(PRESETS) - - assertEquals(PermissionPresetOption.Custom, result) - } - - @Test - fun `ignores enabled permissions that belong to no preset`() { - val share = shareWith( - permission("read", listOf("view", "edit"), enabled = true), - permission("update", listOf("edit"), enabled = false), - permission("delete", listOf("edit"), enabled = false), - permission("download", emptyList(), enabled = true) - ) - - val result = share.getDefaultPermissionPresetOption(PRESETS) - - assertEquals(PermissionPresetOption.Preset(VIEW_PRESET), result) - } - - private fun shareWithEnabled(read: Boolean, update: Boolean, delete: Boolean) = shareWith( - permission("read", listOf("view", "edit"), read), - permission("update", listOf("edit"), update), - permission("delete", listOf("edit"), delete) - ) - - private fun shareWith(vararg permissions: Permission) = Share( - id = "1", - owner = OWNER, - lastUpdated = 0L, - shareState = ShareState.DRAFT, - sources = emptyList(), - recipients = emptyList(), - properties = emptyList(), - permissions = permissions.toList() - ) - - private fun permission(clazz: String, presets: List, enabled: Boolean) = Permission( - clazz = clazz, - displayName = clazz, - priority = 1, - presets = presets, - enabled = enabled - ) - - private companion object { - private val VIEW_PRESET = PermissionPreset(clazz = "view", displayName = "Can view") - private val EDIT_PRESET = PermissionPreset(clazz = "edit", displayName = "Can edit") - private val PRESETS = listOf(VIEW_PRESET, EDIT_PRESET) - private val OWNER = User(userId = "owner", displayName = "Owner", icon = Icon()) - } -} From d409b62f27786294c42b7cb3baa501ca910c7320 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 16:06:02 +0200 Subject: [PATCH 123/144] wip Signed-off-by: alperozturk96 --- .../share/component/bottomsheet/AddOrEditShareBottomSheet.kt | 3 --- 1 file changed, 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 cd21d05d..88676dbc 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 @@ -220,9 +220,6 @@ private fun PermissionsView( permissionPresets: List, viewModel: ShareViewModel ) { - // Null follows the preset the backend reports, which is only known once the draft has a source. - // Anything else is the user's explicit pick and wins from then on, so staying on "Can…" is sticky - // even when the toggles they flip happen to add up to a preset. var userSelection by rememberSaveable(share.id) { mutableStateOf( if (entry == ShareEditorEntry.CUSTOMIZE_PERMISSION) CUSTOM_SELECTION else null From e54809e33dbdb212a748da7eebc696b87de04b27 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 16:19:50 +0200 Subject: [PATCH 124/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 9 +++ .../bottomsheet/AddOrEditShareBottomSheet.kt | 45 ++++++++++++-- .../dialog/DiscardShareConfirmationDialog.kt | 58 +++++++++++++++++++ .../component/property/SharePropertyView.kt | 5 +- .../ui/share/model/api/property/Property.kt | 6 ++ .../common/ui/share/model/api/share/Share.kt | 4 ++ ui/src/main/res/values/strings.xml | 7 +++ 7 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardShareConfirmationDialog.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 8ecaaf03..8ef85565 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 @@ -106,6 +106,9 @@ class ShareViewModel( private val _pendingProperties = MutableStateFlow>(emptySet()) val pendingProperties: StateFlow> = _pendingProperties + private val _unsavedProperties = MutableStateFlow>(emptySet()) + val unsavedProperties: StateFlow> = _unsavedProperties + private val propertyUpdateJobs = mutableMapOf() private var secretUpdateJob: Job? = null @@ -345,9 +348,14 @@ class ShareViewModel( // endregion // region properties + fun onPropertyEdited(clazz: String, unsaved: Boolean) { + _unsavedProperties.update { if (unsaved) it + clazz else it - clazz } + } + fun updateProperty(shareId: String, clazz: String, value: String?) { propertyUpdateJobs[clazz]?.cancel() _pendingProperties.update { it + clazz } + _unsavedProperties.update { it - clazz } if (value.isNullOrEmpty()) { _propertyErrors.update { it - clazz } } @@ -423,6 +431,7 @@ class ShareViewModel( fun setActiveShare(value: Share?, entry: ShareEditorEntry = ShareEditorEntry.EDIT) { _propertyErrors.update { emptyMap() } _pendingProperties.update { emptySet() } + _unsavedProperties.update { emptySet() } _shareReadyToCopy.update { null } updateEditorEntry(entry) updateActiveShare(value?.toActiveShare() ?: ActiveShareState.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 88676dbc..bc4b0dd5 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 @@ -32,6 +32,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SheetValue import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -42,6 +43,7 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -59,6 +61,7 @@ import com.nextcloud.android.common.ui.share.component.CollapsibleShareSection 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.dialog.DiscardShareConfirmationDialog 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 @@ -88,7 +91,22 @@ fun AddOrEditShareBottomSheet( entry: ShareEditorEntry = ShareEditorEntry.EDIT, onDismissDraft: (Share) -> Unit = {} ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val unsavedProperties by viewModel.unsavedProperties.collectAsStateWithLifecycle() + + val hasUnsavedWork by rememberUpdatedState(share.hasUnsentInput || unsavedProperties.isNotEmpty()) + var showDiscardConfirmation by rememberSaveable(share.id) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { value -> + val blocked = value == SheetValue.Hidden && hasUnsavedWork + if (blocked) { + showDiscardConfirmation = true + } + !blocked + } + ) val categories = remember { ShareCategory.entries.toList() } var selectedCategory by rememberSaveable(share.id) { mutableStateOf(if (share.belongsAnyoneTab) ShareCategory.Anyone else ShareCategory.Invited) @@ -106,10 +124,14 @@ fun AddOrEditShareBottomSheet( ) { ModalBottomSheet( onDismissRequest = { - if (share.shareState == ShareState.DRAFT) { - onDismissDraft(share) - } else { - viewModel.setActiveShare(null) + when { + hasUnsavedWork -> { + showDiscardConfirmation = true + scope.launch { sheetState.show() } + } + + share.shareState == ShareState.DRAFT -> onDismissDraft(share) + else -> viewModel.setActiveShare(null) } }, sheetState = sheetState, @@ -182,6 +204,19 @@ fun AddOrEditShareBottomSheet( } } } + + if (showDiscardConfirmation) { + val isDraft = share.shareState == ShareState.DRAFT + + DiscardShareConfirmationDialog( + isDraft = isDraft, + onConfirm = { + showDiscardConfirmation = false + if (isDraft) onDismissDraft(share) else viewModel.setActiveShare(null) + }, + onDismiss = { showDiscardConfirmation = false } + ) + } } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardShareConfirmationDialog.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardShareConfirmationDialog.kt new file mode 100644 index 00000000..4876d6c8 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardShareConfirmationDialog.kt @@ -0,0 +1,58 @@ +/* + * 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.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +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 DiscardShareConfirmationDialog( + isDraft: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val titleId = if (isDraft) { + R.string.share_view_discard_draft_title + } else { + R.string.share_view_discard_changes_title + } + val messageId = if (isDraft) { + R.string.share_view_discard_draft_message + } else { + R.string.share_view_discard_changes_message + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(titleId)) }, + text = { Text(stringResource(messageId)) }, + confirmButton = { + FilledTonalButton( + onClick = onConfirm, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text(stringResource(R.string.share_view_discard_confirm_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.share_view_discard_keep_editing_action)) + } + } + ) +} 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 fcf0fcd9..8fa9c687 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 @@ -127,7 +127,10 @@ private fun ShareTextPropertyField( OutlinedTextField( value = value, - onValueChange = { value = it }, + onValueChange = { + value = it + viewModel.onPropertyEdited(clazz, it != committedValue) + }, label = { Text(displayName) }, visualTransformation = visualTransformation, isError = isError, 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 8be8eaf1..2889d5e0 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 @@ -23,6 +23,9 @@ sealed class Property { abstract val required: Boolean abstract val advanced: Boolean abstract val value: String? + + open val isFilledIn: Boolean + get() = !value.isNullOrBlank() } @Serializable @@ -37,6 +40,9 @@ data class PropertyBoolean( override val value: String? = null ) : Property() { fun isTrue(): Boolean = value == "true" + + override val isFilledIn: Boolean + get() = isTrue() } @Serializable 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 5d62adbb..e6c65ec1 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 @@ -117,6 +117,10 @@ data class Share( val invitedRecipients: List get() = recipients.filterNot { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS } + val hasUnsentInput: Boolean + get() = shareState == ShareState.DRAFT && + (invitedRecipients.isNotEmpty() || properties.any { it.isFilledIn }) + val canSend: Boolean get() { return sources.isNotEmpty() && diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 032954aa..ab720197 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -39,6 +39,13 @@ Delete share? This share will be permanently removed. + Discard this share? + It has not been shared yet, so everything you entered will be lost. + Discard your changes? + What you updated has not been saved yet and will be lost. + Discard + Keep editing + Failed to fetch shares. Failed to delete share. Failed to create share. From e5fc6fea0748e81d6c230b9b2d9963810500ce38 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 29 Jul 2026 16:27:26 +0200 Subject: [PATCH 125/144] wip Signed-off-by: alperozturk96 --- .../component/property/SharePropertyView.kt | 35 +++++++++++-------- .../ui/share/model/api/property/Property.kt | 9 ++++- 2 files changed, 29 insertions(+), 15 deletions(-) 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 8fa9c687..3aaf4853 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 @@ -35,6 +35,10 @@ 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 +private const val SINGLE_LINE_COUNT = 1 +private const val MULTILINE_MIN_LINES = 1 +private const val MULTILINE_MAX_LINES = 3 + @Composable fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewModel) { val propertyErrors by viewModel.propertyErrors.collectAsStateWithLifecycle() @@ -80,18 +84,14 @@ private fun SharePropertyField( is PropertyString -> ShareTextPropertyField( shareId = shareId, - clazz = property.clazz, - displayName = property.displayName, - initialValue = property.value ?: "", + property = property, isError = isError, viewModel = viewModel ) is PropertyPassword -> ShareTextPropertyField( shareId = shareId, - clazz = property.clazz, - displayName = property.displayName, - initialValue = property.value ?: "", + property = property, isError = isError, viewModel = viewModel, visualTransformation = PasswordVisualTransformation() @@ -114,24 +114,29 @@ private fun SharePropertyField( @Composable private fun ShareTextPropertyField( shareId: String, - clazz: String, - displayName: String, - initialValue: String, + property: Property, isError: Boolean, viewModel: ShareViewModel, visualTransformation: VisualTransformation = VisualTransformation.None ) { + val stringProperty = property as? PropertyString + val isMultiline = stringProperty?.isMultiline == true + val maxLength = stringProperty?.maxLength + val clazz = property.clazz + val initialValue = property.value ?: "" var value by remember(clazz) { mutableStateOf(initialValue) } var committedValue by remember(clazz) { mutableStateOf(initialValue) } var wasFocused by remember(clazz) { mutableStateOf(false) } OutlinedTextField( value = value, - onValueChange = { - value = it - viewModel.onPropertyEdited(clazz, it != committedValue) + onValueChange = { newValue -> + if (maxLength == null || newValue.length <= maxLength) { + value = newValue + viewModel.onPropertyEdited(clazz, newValue != committedValue) + } }, - label = { Text(displayName) }, + label = { Text(property.displayName) }, visualTransformation = visualTransformation, isError = isError, modifier = Modifier @@ -144,6 +149,8 @@ private fun ShareTextPropertyField( } wasFocused = focusState.isFocused }, - singleLine = true + singleLine = !isMultiline, + minLines = if (isMultiline) MULTILINE_MIN_LINES else SINGLE_LINE_COUNT, + maxLines = if (isMultiline) MULTILINE_MAX_LINES else SINGLE_LINE_COUNT ) } 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 2889d5e0..07f3aeb7 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 @@ -96,4 +96,11 @@ data class PropertyString( override val value: String? = null, @SerialName("min_length") val minLength: Int? = null, @SerialName("max_length") val maxLength: Int? = null -) : Property() +) : Property() { + companion object { + private const val SINGLE_LINE_MAX_LENGTH = 255 + } + + val isMultiline: Boolean + get() = maxLength == null || maxLength > SINGLE_LINE_MAX_LENGTH +} From accc4f45d36a799ad55fbc594bf1f037add22cf3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 30 Jul 2026 11:02:50 +0200 Subject: [PATCH 126/144] wip Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 98 ++++++++++--------- .../dialog/DiscardShareConfirmationDialog.kt | 58 ----------- ui/src/main/res/values/strings.xml | 7 -- 3 files changed, 52 insertions(+), 111 deletions(-) delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardShareConfirmationDialog.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 bc4b0dd5..3d463b1b 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,6 +18,8 @@ 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.filled.Close import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -27,8 +29,10 @@ import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults @@ -43,14 +47,15 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment 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.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.SavedStateHandle @@ -61,7 +66,6 @@ import com.nextcloud.android.common.ui.share.component.CollapsibleShareSection 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.dialog.DiscardShareConfirmationDialog 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 @@ -91,21 +95,11 @@ fun AddOrEditShareBottomSheet( entry: ShareEditorEntry = ShareEditorEntry.EDIT, onDismissDraft: (Share) -> Unit = {} ) { - val unsavedProperties by viewModel.unsavedProperties.collectAsStateWithLifecycle() - - val hasUnsavedWork by rememberUpdatedState(share.hasUnsentInput || unsavedProperties.isNotEmpty()) - var showDiscardConfirmation by rememberSaveable(share.id) { mutableStateOf(false) } + var dismissAllowed by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, - confirmValueChange = { value -> - val blocked = value == SheetValue.Hidden && hasUnsavedWork - if (blocked) { - showDiscardConfirmation = true - } - !blocked - } + confirmValueChange = { value -> dismissAllowed || value != SheetValue.Hidden } ) val categories = remember { ShareCategory.entries.toList() } var selectedCategory by rememberSaveable(share.id) { @@ -123,18 +117,10 @@ fun AddOrEditShareBottomSheet( .fillMaxSize() ) { ModalBottomSheet( - onDismissRequest = { - when { - hasUnsavedWork -> { - showDiscardConfirmation = true - scope.launch { sheetState.show() } - } - - share.shareState == ShareState.DRAFT -> onDismissDraft(share) - else -> viewModel.setActiveShare(null) - } - }, + onDismissRequest = {}, sheetState = sheetState, + dragHandle = null, + properties = ModalBottomSheetProperties(shouldDismissOnBackPress = false), containerColor = MaterialTheme.colorScheme.surface, ) { Column( @@ -142,12 +128,37 @@ fun AddOrEditShareBottomSheet( .fillMaxWidth() .verticalScroll(rememberScrollState()) ) { - Text( - text = share.title(context), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(16.dp) - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = share.title(context), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + IconButton(onClick = { + dismissAllowed = true + scope.launch { + sheetState.hide() + if (share.shareState == ShareState.DRAFT) onDismissDraft(share) else viewModel.setActiveShare( + null + ) + } + }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "close bottom sheet", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } ShareCategorySelector( share = share, @@ -204,19 +215,6 @@ fun AddOrEditShareBottomSheet( } } } - - if (showDiscardConfirmation) { - val isDraft = share.shareState == ShareState.DRAFT - - DiscardShareConfirmationDialog( - isDraft = isDraft, - onConfirm = { - showDiscardConfirmation = false - if (isDraft) onDismissDraft(share) else viewModel.setActiveShare(null) - }, - onDismiss = { showDiscardConfirmation = false } - ) - } } } @@ -231,7 +229,11 @@ private fun ShareCategorySelector( // only allow user to select between taps if it is draft share if (share.shareState != ShareState.DRAFT) return - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { categories.forEachIndexed { index, category -> SegmentedButton( selected = selectedCategory == category, @@ -346,7 +348,11 @@ private fun BasicSettingsSection( return } - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { share.basicProperties.forEach { property -> key(property.clazz) { SharePropertyView( diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardShareConfirmationDialog.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardShareConfirmationDialog.kt deleted file mode 100644 index 4876d6c8..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardShareConfirmationDialog.kt +++ /dev/null @@ -1,58 +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.ButtonDefaults -import androidx.compose.material3.FilledTonalButton -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 DiscardShareConfirmationDialog( - isDraft: Boolean, - onConfirm: () -> Unit, - onDismiss: () -> Unit -) { - val titleId = if (isDraft) { - R.string.share_view_discard_draft_title - } else { - R.string.share_view_discard_changes_title - } - val messageId = if (isDraft) { - R.string.share_view_discard_draft_message - } else { - R.string.share_view_discard_changes_message - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(titleId)) }, - text = { Text(stringResource(messageId)) }, - confirmButton = { - FilledTonalButton( - onClick = onConfirm, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) - ) { - Text(stringResource(R.string.share_view_discard_confirm_action)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.share_view_discard_keep_editing_action)) - } - } - ) -} diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index ab720197..032954aa 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -39,13 +39,6 @@ Delete share? This share will be permanently removed. - Discard this share? - It has not been shared yet, so everything you entered will be lost. - Discard your changes? - What you updated has not been saved yet and will be lost. - Discard - Keep editing - Failed to fetch shares. Failed to delete share. Failed to create share. From 9c835951d781e3aa7d0986d29728522444b9c9fa Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 30 Jul 2026 11:14:57 +0200 Subject: [PATCH 127/144] wip Signed-off-by: alperozturk96 --- .../bottomsheet/AddOrEditShareBottomSheet.kt | 11 ----------- ui/src/main/res/values/strings.xml | 1 - 2 files changed, 12 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 3d463b1b..9be66861 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 @@ -193,17 +193,6 @@ fun AddOrEditShareBottomSheet( viewModel = viewModel ) - 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.canSend) { ActionButtons( share = share, diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 032954aa..38bb745f 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -43,7 +43,6 @@ Failed to delete share. Failed to create share. Failed to update share. - Some settings couldn\'t be saved. Fix the highlighted fields before sharing. Change permission Participants From ee72dba834cd04206699161df9a6268af0e405ac Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 10 Aug 2026 12:19:16 +0200 Subject: [PATCH 128/144] wip Signed-off-by: alperozturk96 --- .../property/datepicker/ShareDatePicker.kt | 66 ++++++++++++------- .../ui/util/extensions/StringExtensions.kt | 12 ++++ ui/src/main/res/drawable/ic_cancel.xml | 20 ++++++ 3 files changed, 75 insertions(+), 23 deletions(-) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/StringExtensions.kt create mode 100644 ui/src/main/res/drawable/ic_cancel.xml 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 5bf623b3..a8187db6 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 @@ -7,18 +7,20 @@ 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.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.fillMaxWidth 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 import androidx.compose.material3.rememberDatePickerState 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 @@ -31,6 +33,7 @@ import com.nextcloud.android.common.ui.R import com.nextcloud.android.common.ui.share.component.property.datepicker.util.NoPastSelectableDates import com.nextcloud.android.common.ui.share.component.property.datepicker.util.ShareDateFormatter import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate +import com.nextcloud.android.common.ui.util.extensions.Strings @Composable fun ShareDatePicker(property: PropertyDate, isError: Boolean, onDateSelected: (String) -> Unit) { @@ -38,36 +41,53 @@ fun ShareDatePicker(property: PropertyDate, isError: Boolean, onDateSelected: (S var showDatePicker by remember { mutableStateOf(false) } var dateValue by remember(property.clazz) { mutableStateOf(formatter.formatIsoForDisplay(property.value)) } - Box(modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( - value = dateValue, - onValueChange = {}, - readOnly = true, - enabled = true, - isError = isError, - modifier = Modifier.fillMaxWidth(), - label = { Text(formatter.getDisplayName(property)) }, - trailingIcon = { + val interactionSource = remember { MutableInteractionSource() } + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + if (interaction is PressInteraction.Release) { + showDatePicker = true + } + } + } + + OutlinedTextField( + value = dateValue, + onValueChange = {}, + readOnly = true, + enabled = true, + isError = isError, + modifier = Modifier.fillMaxWidth(), + interactionSource = interactionSource, + label = { Text(formatter.getDisplayName(property)) }, + trailingIcon = { + val isDateBlank = dateValue.isBlank() + val iconId = if (isDateBlank) { + R.drawable.ic_calendar + } else { + R.drawable.ic_cancel + } + IconButton(onClick = { + if (isDateBlank) { + showDatePicker = true + } else { + dateValue = Strings.EMPTY + onDateSelected(Strings.EMPTY) + } + }) { Icon( - painter = painterResource(R.drawable.ic_calendar), + painter = painterResource(iconId), contentDescription = null ) } - ) - - Box( - modifier = Modifier - .matchParentSize() - .clickable { showDatePicker = true } - ) - } + } + ) if (showDatePicker) { DatePickerModal( formatter = formatter, onDateSelected = { displayDate, isoDate -> - dateValue = displayDate ?: "" - onDateSelected(isoDate ?: "") + dateValue = displayDate ?: Strings.EMPTY + onDateSelected(isoDate ?: Strings.EMPTY) }, onDismiss = { showDatePicker = false } ) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/StringExtensions.kt b/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/StringExtensions.kt new file mode 100644 index 00000000..954245fe --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/StringExtensions.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.util.extensions + +object Strings { + const val EMPTY = "" +} diff --git a/ui/src/main/res/drawable/ic_cancel.xml b/ui/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 00000000..5d968727 --- /dev/null +++ b/ui/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,20 @@ + + + + + + From 26a27c8ac814776d3760f1505a85306cecc267ea Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 10 Aug 2026 12:28:29 +0200 Subject: [PATCH 129/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 4 ++- .../android/common/ui/share/ShareViewModel.kt | 25 +++++++------------ .../component/property/SharePropertyView.kt | 1 - .../ui/share/model/ui/ShareScreenState.kt | 2 +- 4 files changed, 13 insertions(+), 19 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 78d256ec..4f2e89f0 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 @@ -141,6 +141,7 @@ private fun ShareScreen( } is ShareScreenState.Empty -> ShareList( + isRefreshing = false, shares = emptyList(), permissionPresets = permissionPresets, paddingValues = paddingValues, @@ -148,6 +149,7 @@ private fun ShareScreen( ) is ShareScreenState.Loaded -> ShareList( + isRefreshing = state.refreshing, shares = state.shares, permissionPresets = permissionPresets, paddingValues = paddingValues, @@ -174,13 +176,13 @@ private fun ShareScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ShareList( + isRefreshing: Boolean, shares: List, permissionPresets: List, paddingValues: PaddingValues, viewModel: ShareViewModel ) { val context = LocalContext.current - val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() PullToRefreshBox( isRefreshing = isRefreshing, 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 8ef85565..f281071f 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 @@ -69,9 +69,6 @@ class ShareViewModel( private val _state = MutableStateFlow(ShareScreenState.Loading) val state: StateFlow = _state - private val _isRefreshing = MutableStateFlow(false) - val isRefreshing: StateFlow = _isRefreshing - private val _isPreparingLink = MutableStateFlow(false) val isPreparingLink: StateFlow = _isPreparingLink @@ -106,9 +103,6 @@ class ShareViewModel( private val _pendingProperties = MutableStateFlow>(emptySet()) val pendingProperties: StateFlow> = _pendingProperties - private val _unsavedProperties = MutableStateFlow>(emptySet()) - val unsavedProperties: StateFlow> = _unsavedProperties - private val propertyUpdateJobs = mutableMapOf() private var secretUpdateJob: Job? = null @@ -164,12 +158,17 @@ class ShareViewModel( // region shares list fun refreshShares() { - if (_isRefreshing.value) return + val state = _state.value as? ShareScreenState.Loaded ?: return + if (state.refreshing) return viewModelScope.launch(Dispatchers.IO) { - _isRefreshing.update { true } + _state.update { + state.copy(refreshing = true) + } loadShares() - _isRefreshing.update { false } + _state.update { + state.copy(refreshing = false) + } } } @@ -348,14 +347,9 @@ class ShareViewModel( // endregion // region properties - fun onPropertyEdited(clazz: String, unsaved: Boolean) { - _unsavedProperties.update { if (unsaved) it + clazz else it - clazz } - } - fun updateProperty(shareId: String, clazz: String, value: String?) { propertyUpdateJobs[clazz]?.cancel() _pendingProperties.update { it + clazz } - _unsavedProperties.update { it - clazz } if (value.isNullOrEmpty()) { _propertyErrors.update { it - clazz } } @@ -431,7 +425,6 @@ class ShareViewModel( fun setActiveShare(value: Share?, entry: ShareEditorEntry = ShareEditorEntry.EDIT) { _propertyErrors.update { emptyMap() } _pendingProperties.update { emptySet() } - _unsavedProperties.update { emptySet() } _shareReadyToCopy.update { null } updateEditorEntry(entry) updateActiveShare(value?.toActiveShare() ?: ActiveShareState.None) @@ -474,7 +467,7 @@ class ShareViewModel( private fun publishShares(shares: List) { _state.update { - if (shares.isEmpty()) ShareScreenState.Empty else ShareScreenState.Loaded(shares) + if (shares.isEmpty()) ShareScreenState.Empty else ShareScreenState.Loaded(shares, false) } } // endregion 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 3aaf4853..20c191c1 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 @@ -133,7 +133,6 @@ private fun ShareTextPropertyField( onValueChange = { newValue -> if (maxLength == null || newValue.length <= maxLength) { value = newValue - viewModel.onPropertyEdited(clazz, newValue != committedValue) } }, label = { Text(property.displayName) }, 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 728af1a8..81fb4038 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 @@ -13,7 +13,7 @@ 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() + data class Loaded(val shares: List, val refreshing: Boolean): ShareScreenState() } fun List.activeOnly(): List = filter { it.shareState == ShareState.ACTIVE } From 8e963639a2b7a72bfdc74fefd5950353d7903646 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 10 Aug 2026 13:37:45 +0200 Subject: [PATCH 130/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 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 f281071f..19d34117 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 @@ -162,21 +162,22 @@ class ShareViewModel( if (state.refreshing) return viewModelScope.launch(Dispatchers.IO) { - _state.update { - state.copy(refreshing = true) - } + setRefreshing(true) loadShares() - _state.update { - state.copy(refreshing = false) - } + setRefreshing(false) + } + } + + private fun setRefreshing(refreshing: Boolean) { + _state.update { + if (it is ShareScreenState.Loaded) it.copy(refreshing = refreshing) else it } } private suspend fun loadShares() { _errorMessageId.update { null } - - val fetched = fetchAllShares() ?: return - publishShares(fetched.activeOnly()) + val fetched = fetchAllShares() + publishShares(fetched?.activeOnly() ?: currentShares) } // The server pages drafts and active shares together while the list only renders active ones, From bef4e9381bc3d8920aaf96e903a75269cee64d5f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 10 Aug 2026 13:42:31 +0200 Subject: [PATCH 131/144] wip Signed-off-by: alperozturk96 --- .../nextcloud/android/common/ui/share/ShareViewModel.kt | 8 ++------ .../component/bottomsheet/AddOrEditShareBottomSheet.kt | 7 ++++--- 2 files changed, 6 insertions(+), 9 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 19d34117..cc6b44cb 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 @@ -72,9 +72,6 @@ class ShareViewModel( private val _isPreparingLink = MutableStateFlow(false) val isPreparingLink: StateFlow = _isPreparingLink - private val _shareReadyToCopy = MutableStateFlow(null) - val shareReadyToCopy: StateFlow = _shareReadyToCopy - private val _activeShare = MutableStateFlow(ActiveShareState.None) val activeShare: StateFlow = _activeShare @@ -249,7 +246,6 @@ class ShareViewModel( if (updated != null) { refreshActiveShare(ActiveShareState.Activating(updated)) replaceInList(updated) - _shareReadyToCopy.update { updated } } _isPreparingLink.update { false } @@ -257,7 +253,8 @@ class ShareViewModel( } fun onLinkCopied() { - _shareReadyToCopy.update { null } + val activating = _activeShare.value as? ActiveShareState.Activating ?: return + updateActiveShare(ActiveShareState.Editing(activating.share)) } private suspend fun applyState(id: String, shareState: ShareState): Share? { @@ -426,7 +423,6 @@ class ShareViewModel( fun setActiveShare(value: Share?, entry: ShareEditorEntry = ShareEditorEntry.EDIT) { _propertyErrors.update { emptyMap() } _pendingProperties.update { emptySet() } - _shareReadyToCopy.update { null } updateEditorEntry(entry) updateActiveShare(value?.toActiveShare() ?: ActiveShareState.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 9be66861..9a567bc7 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 @@ -76,6 +76,7 @@ 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.ActiveShareState 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.model.ui.ShareEditorEntry @@ -403,10 +404,10 @@ private fun ActionButtons( val localClipboard = LocalClipboard.current val scope = rememberCoroutineScope() val isPreparingLink by viewModel.isPreparingLink.collectAsStateWithLifecycle() - val shareReadyToCopy by viewModel.shareReadyToCopy.collectAsStateWithLifecycle() + val activeShare by viewModel.activeShare.collectAsStateWithLifecycle() - LaunchedEffect(shareReadyToCopy) { - val ready = shareReadyToCopy ?: return@LaunchedEffect + LaunchedEffect(activeShare) { + val ready = (activeShare as? ActiveShareState.Activating)?.share ?: return@LaunchedEffect ready.getClipEntry(internalLink, category)?.let { localClipboard.setClipEntry(it) } viewModel.onLinkCopied() } From a7b4bf29783bb218147ce1114c2dbbb8773419f4 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 10 Aug 2026 13:43:00 +0200 Subject: [PATCH 132/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareViewModel.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 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 cc6b44cb..1b822a59 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 @@ -78,16 +78,6 @@ class ShareViewModel( private val _editorEntry = MutableStateFlow(savedState.editorEntry) val editorEntry: StateFlow = _editorEntry - val searchQuery: StateFlow = savedState.searchQuery - - @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) - val recipientSearchResults: StateFlow> = searchQuery - .debounce(SEARCH_DEBOUNCE_DELAY.milliseconds) - .distinctUntilChanged() - .filter { it.isNotBlank() } - .mapLatest { query -> searchRecipients(query) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SEARCH_SUBSCRIPTION_TIMEOUT), emptyList()) - private val _permissionPresets = MutableStateFlow>(emptyList()) val permissionPresets: StateFlow> = _permissionPresets @@ -100,12 +90,22 @@ class ShareViewModel( private val _pendingProperties = MutableStateFlow>(emptySet()) val pendingProperties: StateFlow> = _pendingProperties + private val currentShares: List + get() = (_state.value as? ShareScreenState.Loaded)?.shares ?: emptyList() + private val propertyUpdateJobs = mutableMapOf() - private var secretUpdateJob: Job? = null + val searchQuery: StateFlow = savedState.searchQuery - private val currentShares: List - get() = (_state.value as? ShareScreenState.Loaded)?.shares ?: emptyList() + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val recipientSearchResults: StateFlow> = searchQuery + .debounce(SEARCH_DEBOUNCE_DELAY.milliseconds) + .distinctUntilChanged() + .filter { it.isNotBlank() } + .mapLatest { query -> searchRecipients(query) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SEARCH_SUBSCRIPTION_TIMEOUT), emptyList()) + + private var secretUpdateJob: Job? = null init { loadInitialData() From 307af94b2ca55b6f247e05ff600c62e94731c341 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 10 Aug 2026 13:46:31 +0200 Subject: [PATCH 133/144] wip Signed-off-by: alperozturk96 --- .../java/com/nextcloud/android/common/ui/share/ShareScreen.kt | 2 -- .../ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt | 2 +- 2 files changed, 1 insertion(+), 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 4f2e89f0..88c7fa4b 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 @@ -106,7 +106,6 @@ private fun ShareScreen( val screenState by viewModel.state.collectAsStateWithLifecycle() val activeShare by viewModel.activeShare.collectAsStateWithLifecycle() val permissionPresets by viewModel.permissionPresets.collectAsStateWithLifecycle() - val editorEntry by viewModel.editorEntry.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val resources = LocalResources.current @@ -164,7 +163,6 @@ private fun ShareScreen( internalLink = internalLink, viewModel = viewModel, permissionPresets = permissionPresets, - entry = editorEntry, onDismissDraft = { draftShare -> viewModel.deleteShare(draftShare.id) viewModel.setActiveShare(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 9a567bc7..00ade30e 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 @@ -93,9 +93,9 @@ fun AddOrEditShareBottomSheet( internalLink: String, viewModel: ShareViewModel, permissionPresets: List = emptyList(), - entry: ShareEditorEntry = ShareEditorEntry.EDIT, onDismissDraft: (Share) -> Unit = {} ) { + val entry by viewModel.editorEntry.collectAsStateWithLifecycle() var dismissAllowed by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState( From c829eadb21fd8ad6496700482def71f92def7158 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 10 Aug 2026 13:52:27 +0200 Subject: [PATCH 134/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/model/api/share/Share.kt | 11 ++--------- ui/src/main/res/values/strings.xml | 3 +-- 2 files changed, 3 insertions(+), 11 deletions(-) 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 e6c65ec1..e77041ea 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 @@ -117,10 +117,6 @@ data class Share( val invitedRecipients: List get() = recipients.filterNot { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS } - val hasUnsentInput: Boolean - get() = shareState == ShareState.DRAFT && - (invitedRecipients.isNotEmpty() || properties.any { it.isFilledIn }) - val canSend: Boolean get() { return sources.isNotEmpty() && @@ -130,10 +126,7 @@ data class Share( } fun title(context: Context): String { - return if (shareState == ShareState.DRAFT) { - context.getString(R.string.share_view_bottom_sheet_new_title) - } else { - context.getString(R.string.share_view_bottom_sheet_edit_title, sources.firstOrNull()?.displayName) - } + val firstRecipient = sources.firstOrNull()?.displayName ?: "..." + return context.getString(R.string.share_view_bottom_sheet_title, firstRecipient) } } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 38bb745f..a74584db 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -12,8 +12,7 @@ Share link Share link (%1$d) - Create a new share - Share %s + Share "%s" Settings From 4fcaf366d6d08ffc4d9be251e259a637b18b7522 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 10 Aug 2026 13:53:44 +0200 Subject: [PATCH 135/144] wip Signed-off-by: alperozturk96 --- .../java/com/nextcloud/android/common/ui/share/ShareScreen.kt | 2 ++ .../android/common/ui/share/component/SelectRecipientField.kt | 2 +- .../ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt | 2 +- .../common/ui/share/component/property/SharePropertyView.kt | 2 +- .../android/common/ui/share/{ => viewmodel}/ShareSavedState.kt | 2 +- .../android/common/ui/share/{ => viewmodel}/ShareViewModel.kt | 2 +- .../common/ui/share/{ => viewmodel}/ShareViewModelFactory.kt | 2 +- 7 files changed, 8 insertions(+), 6 deletions(-) rename ui/src/main/java/com/nextcloud/android/common/ui/share/{ => viewmodel}/ShareSavedState.kt (95%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/{ => viewmodel}/ShareViewModel.kt (99%) rename ui/src/main/java/com/nextcloud/android/common/ui/share/{ => viewmodel}/ShareViewModelFactory.kt (95%) 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 88c7fa4b..e0f046fe 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 @@ -93,6 +93,8 @@ 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.label import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository +import com.nextcloud.android.common.ui.share.viewmodel.ShareViewModel +import com.nextcloud.android.common.ui.share.viewmodel.ShareViewModelFactory private val FIRST_ITEM_TOP_SPACING = 16.dp private val ITEM_SPACING = 2.dp diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt index 4d1a0f3b..05521c60 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt @@ -42,7 +42,7 @@ 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.viewmodel.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 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 00ade30e..8b51b58f 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 @@ -61,7 +61,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nextcloud.android.common.ui.R -import com.nextcloud.android.common.ui.share.ShareViewModel +import com.nextcloud.android.common.ui.share.viewmodel.ShareViewModel import com.nextcloud.android.common.ui.share.component.CollapsibleShareSection import com.nextcloud.android.common.ui.share.component.CustomLink import com.nextcloud.android.common.ui.share.component.SelectRecipientField 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 20c191c1..6a1a3173 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 @@ -25,7 +25,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nextcloud.android.common.ui.R -import com.nextcloud.android.common.ui.share.ShareViewModel +import com.nextcloud.android.common.ui.share.viewmodel.ShareViewModel 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 diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareSavedState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareSavedState.kt similarity index 95% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/ShareSavedState.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareSavedState.kt index 63753bcf..3b746273 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareSavedState.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareSavedState.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.share +package com.nextcloud.android.common.ui.share.viewmodel import androidx.lifecycle.SavedStateHandle import com.nextcloud.android.common.ui.share.model.ui.ShareEditorEntry 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/viewmodel/ShareViewModel.kt similarity index 99% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt index 1b822a59..2683aa0e 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/viewmodel/ShareViewModel.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.share +package com.nextcloud.android.common.ui.share.viewmodel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModelFactory.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModelFactory.kt similarity index 95% rename from ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModelFactory.kt rename to ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModelFactory.kt index b6fa9307..cea0cdda 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModelFactory.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModelFactory.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: MIT */ -package com.nextcloud.android.common.ui.share +package com.nextcloud.android.common.ui.share.viewmodel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel From 5f77c1528840d940f7b28c3d312d34d6b59574c0 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 10 Aug 2026 14:22:28 +0200 Subject: [PATCH 136/144] wip Signed-off-by: alperozturk96 --- .../ui/share/avatar/ShareAvatarRepository.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/avatar/ShareAvatarRepository.kt diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/avatar/ShareAvatarRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/avatar/ShareAvatarRepository.kt new file mode 100644 index 00000000..6316f315 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/avatar/ShareAvatarRepository.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.avatar + +import android.util.Log +import com.nextcloud.android.common.ui.network.auth.ServerCredentials +import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient +import com.nextcloud.android.common.ui.network.model.dataOrElse +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.repository.ShareRemoteRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ShareAvatarRepository(private val credentials: ServerCredentials) { + companion object { + private const val TAG = "ShareAvatarRepository" + private const val SHARES_PAGE_SIZE = 3 + } + + suspend fun fetchShareAvatars(sourceId: String): List? = withContext(Dispatchers.IO) { + val client = NextcloudHttpClient.create(credentials) + val shareRepository = ShareRemoteRepository(client) + val result = shareRepository.fetchShares( + filterSourceTypeValue = sourceId, + limit = SHARES_PAGE_SIZE, + filterSourceTypeClass = Source.NODE_SOURCE_CLASS, + lastShareID = null + ) + return@withContext result.dataOrElse { Log.e(TAG, "failed to fetch avatars") } + } +} From bc656c81d2bf8c359c0afdb056714cd29d727051 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 11:08:00 +0200 Subject: [PATCH 137/144] wip Signed-off-by: alperozturk96 --- .../share/repository/MockShareRepository.kt | 11 ++---- .../share/repository/ShareRemoteRepository.kt | 2 +- .../ui/share/repository/ShareRepository.kt | 2 +- .../ui/share/viewmodel/ShareViewModel.kt | 36 ++++--------------- 4 files changed, 11 insertions(+), 40 deletions(-) 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 1a2bb62d..381753f9 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 @@ -325,23 +325,18 @@ class MockShareRepository : ShareRepository { override suspend fun fetchShares( filterSourceTypeValue: String, - limit: Int, + limit: Int?, filterSourceTypeClass: String?, lastShareID: String? ): NetworkResult> { - var result = mockShares.filter { share -> + val result = mockShares.filter { share -> share.sources.any { it.value == filterSourceTypeValue && (filterSourceTypeClass == null || it.clazz == filterSourceTypeClass) } } - if (lastShareID != null) { - val index = result.indexOfFirst { it.id == lastShareID } - if (index >= 0) result = result.drop(index + 1) - } - - return NetworkResult.Success(result.take(limit)) + return NetworkResult.Success(result) } override suspend fun updateShareState( 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 0e20df77..c0069d7a 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 @@ -93,7 +93,7 @@ class ShareRemoteRepository( override suspend fun fetchShares( filterSourceTypeValue: String, - limit: Int, + limit: Int?, filterSourceTypeClass: String?, lastShareID: String? ): NetworkResult> { 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 6f5cae0d..929707a0 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 @@ -39,7 +39,7 @@ interface ShareRepository { suspend fun fetchShares( filterSourceTypeValue: String, - limit: Int, + limit: Int? = null, filterSourceTypeClass: String? = null, lastShareID: String? = null ): NetworkResult> diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt index 2683aa0e..442af66f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt @@ -60,8 +60,6 @@ class ShareViewModel( private const val SEARCH_SUBSCRIPTION_TIMEOUT = 5_000L private const val RECIPIENT_SEARCH_LIMIT = 10 private const val RECIPIENT_SEARCH_OFFSET = 0 - private const val SHARES_PAGE_SIZE = 100 - private const val MAX_SHARE_PAGES = 20 } private val savedState = ShareSavedState(savedStateHandle) @@ -173,35 +171,13 @@ class ShareViewModel( private suspend fun loadShares() { _errorMessageId.update { null } - val fetched = fetchAllShares() - publishShares(fetched?.activeOnly() ?: currentShares) - } - - // The server pages drafts and active shares together while the list only renders active ones, - // so a file with a page worth of abandoned drafts would otherwise hide every real share. - private suspend fun fetchAllShares(): List? { - val shares = mutableListOf() - var cursor: String? = null - var page = 0 - - while (page < MAX_SHARE_PAGES) { - val result = repository.fetchShares( - filterSourceTypeValue = sourceId, - limit = SHARES_PAGE_SIZE, - filterSourceTypeClass = Source.NODE_SOURCE_CLASS, - lastShareID = cursor - ) - val fetched = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } - ?: return null - - shares += fetched - if (fetched.size < SHARES_PAGE_SIZE) break - - cursor = fetched.last().id - page++ + val result = repository.fetchShares( + filterSourceTypeValue = sourceId, + filterSourceTypeClass = Source.NODE_SOURCE_CLASS + ) + result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } }?.let { fetched -> + publishShares(fetched.activeOnly()) } - - return shares } // endregion From a7b76ae93eda9c021abea5d0c02e9618d88ce71e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 11:13:30 +0200 Subject: [PATCH 138/144] wip Signed-off-by: alperozturk96 --- .../common/ui/share/repository/ShareRemoteRepository.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 c0069d7a..36384705 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 @@ -98,8 +98,8 @@ class ShareRemoteRepository( lastShareID: String? ): NetworkResult> { val queryParams = buildString { - append("?limit=$limit") - append("&filterSourceTypeValue=${filterSourceTypeValue.urlEncoded()}") + append("?filterSourceTypeValue=${filterSourceTypeValue.urlEncoded()}") + limit?.let { append("&limit=$it") } filterSourceTypeClass?.let { append("&filterSourceTypeClass=${it.urlEncoded()}") } lastShareID?.let { append("&lastShareID=${it.urlEncoded()}") } } From 017f4bb2a4cfa8729f93e4016857f071d5d1718f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 11:21:02 +0200 Subject: [PATCH 139/144] wip Signed-off-by: alperozturk96 --- .../com/nextcloud/android/common/ui/share/ShareScreen.kt | 8 +++++++- ui/src/main/res/values/strings.xml | 3 ++- 2 files changed, 9 insertions(+), 2 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 e0f046fe..929c0a68 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 @@ -101,6 +101,7 @@ private val ITEM_SPACING = 2.dp @Composable private fun ShareScreen( + filename: String, internalLink: String, viewModel: ShareViewModel ) { @@ -142,6 +143,7 @@ private fun ShareScreen( } is ShareScreenState.Empty -> ShareList( + filename = filename, isRefreshing = false, shares = emptyList(), permissionPresets = permissionPresets, @@ -150,6 +152,7 @@ private fun ShareScreen( ) is ShareScreenState.Loaded -> ShareList( + filename = filename, isRefreshing = state.refreshing, shares = state.shares, permissionPresets = permissionPresets, @@ -176,6 +179,7 @@ private fun ShareScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ShareList( + filename: String, isRefreshing: Boolean, shares: List, permissionPresets: List, @@ -201,6 +205,7 @@ private fun ShareList( ContentUnavailableView( iconId = R.drawable.ic_person_add, title = stringResource(R.string.share_view_empty_title), + description = stringResource(R.string.share_view_empty_description, filename) ) } } @@ -423,6 +428,7 @@ private fun ShareItem( fun ComposeView.initShareScreen( viewModelStoreOwner: ViewModelStoreOwner, sourceId: String, + filename: String, internalLink: String, credentials: ServerCredentials, colorScheme: ColorScheme @@ -442,7 +448,7 @@ fun ComposeView.initShareScreen( MaterialTheme( colorScheme = colorScheme, content = { - ShareScreen(internalLink, viewModel) + ShareScreen(filename, internalLink, viewModel) } ) } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index a74584db..592e8ab0 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -23,7 +23,8 @@ 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 shares yet + Use + button to share %s No result Copied From e161899f440c9e91992972846f58ba8396b27026 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 11:24:38 +0200 Subject: [PATCH 140/144] wip Signed-off-by: alperozturk96 --- .../share/component/bottomsheet/AddOrEditShareBottomSheet.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 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 8b51b58f..5f2e375b 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 @@ -139,8 +139,9 @@ fun AddOrEditShareBottomSheet( text = share.title(context), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.MiddleEllipsis, modifier = Modifier.weight(1f) ) From 0a0e2b1cd142ea944491601fbbee58fe59d22e29 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 11:25:51 +0200 Subject: [PATCH 141/144] wip Signed-off-by: alperozturk96 --- .../nextcloud/android/common/ui/share/ShareScreen.kt | 10 ++-------- ui/src/main/res/values/strings.xml | 2 -- 2 files changed, 2 insertions(+), 10 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 929c0a68..8827694f 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 @@ -101,7 +101,6 @@ private val ITEM_SPACING = 2.dp @Composable private fun ShareScreen( - filename: String, internalLink: String, viewModel: ShareViewModel ) { @@ -143,7 +142,6 @@ private fun ShareScreen( } is ShareScreenState.Empty -> ShareList( - filename = filename, isRefreshing = false, shares = emptyList(), permissionPresets = permissionPresets, @@ -152,7 +150,6 @@ private fun ShareScreen( ) is ShareScreenState.Loaded -> ShareList( - filename = filename, isRefreshing = state.refreshing, shares = state.shares, permissionPresets = permissionPresets, @@ -179,7 +176,6 @@ private fun ShareScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ShareList( - filename: String, isRefreshing: Boolean, shares: List, permissionPresets: List, @@ -204,8 +200,7 @@ private fun ShareList( Box(modifier = Modifier.fillParentMaxSize()) { ContentUnavailableView( iconId = R.drawable.ic_person_add, - title = stringResource(R.string.share_view_empty_title), - description = stringResource(R.string.share_view_empty_description, filename) + title = stringResource(R.string.share_view_empty_title) ) } } @@ -428,7 +423,6 @@ private fun ShareItem( fun ComposeView.initShareScreen( viewModelStoreOwner: ViewModelStoreOwner, sourceId: String, - filename: String, internalLink: String, credentials: ServerCredentials, colorScheme: ColorScheme @@ -448,7 +442,7 @@ fun ComposeView.initShareScreen( MaterialTheme( colorScheme = colorScheme, content = { - ShareScreen(filename, internalLink, viewModel) + ShareScreen(internalLink, viewModel) } ) } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 592e8ab0..f07b3bdf 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -20,11 +20,9 @@ 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 No shares yet - Use + button to share %s No result Copied From 720c68a5421b4712f5b29ebc51af996099ac2b12 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 11:26:57 +0200 Subject: [PATCH 142/144] wip Signed-off-by: alperozturk96 --- ui/src/main/res/values/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index f07b3bdf..c37293eb 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ 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 No shares yet From 1f2dd51f4a87a878ea91f1d254b89b806fa49852 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 11:47:28 +0200 Subject: [PATCH 143/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 6 +--- .../bottomsheet/AddOrEditShareBottomSheet.kt | 32 +++++++++---------- .../ui/share/viewmodel/ShareViewModel.kt | 32 +++++++++++++++---- 3 files changed, 41 insertions(+), 29 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 8827694f..0de17b3c 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 @@ -164,11 +164,7 @@ private fun ShareScreen( share = activeShareObject, internalLink = internalLink, viewModel = viewModel, - permissionPresets = permissionPresets, - onDismissDraft = { draftShare -> - viewModel.deleteShare(draftShare.id) - viewModel.setActiveShare(null) - } + permissionPresets = permissionPresets ) } } 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 5f2e375b..3dd8386a 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 @@ -36,7 +36,6 @@ import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SheetValue import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -49,6 +48,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard @@ -82,6 +82,7 @@ import com.nextcloud.android.common.ui.share.model.ui.ShareCategory import com.nextcloud.android.common.ui.share.model.ui.ShareEditorEntry import com.nextcloud.android.common.ui.share.model.ui.label import com.nextcloud.android.common.ui.share.repository.MockShareRepository +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch private const val CUSTOM_SELECTION = "custom" @@ -92,16 +93,12 @@ fun AddOrEditShareBottomSheet( share: Share, internalLink: String, viewModel: ShareViewModel, - permissionPresets: List = emptyList(), - onDismissDraft: (Share) -> Unit = {} + permissionPresets: List = emptyList() ) { val entry by viewModel.editorEntry.collectAsStateWithLifecycle() - var dismissAllowed by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { value -> dismissAllowed || value != SheetValue.Hidden } - ) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val dismissSheet = { viewModel.dismissActiveShare(share.id) } val categories = remember { ShareCategory.entries.toList() } var selectedCategory by rememberSaveable(share.id) { mutableStateOf(if (share.belongsAnyoneTab) ShareCategory.Anyone else ShareCategory.Invited) @@ -113,15 +110,22 @@ fun AddOrEditShareBottomSheet( val hasPropertyErrors = propertyErrors.isNotEmpty() val sendEnabled = !hasPropertyErrors && pendingProperties.isEmpty() + LaunchedEffect(sheetState, share.id) { + snapshotFlow { sheetState.isVisible }.first { it } + snapshotFlow { sheetState.isVisible }.first { !it } + dismissSheet() + } + Column( modifier = Modifier .fillMaxSize() ) { ModalBottomSheet( - onDismissRequest = {}, + onDismissRequest = dismissSheet, sheetState = sheetState, + sheetGesturesEnabled = false, dragHandle = null, - properties = ModalBottomSheetProperties(shouldDismissOnBackPress = false), + properties = ModalBottomSheetProperties(shouldDismissOnClickOutside = false), containerColor = MaterialTheme.colorScheme.surface, ) { Column( @@ -146,13 +150,7 @@ fun AddOrEditShareBottomSheet( ) IconButton(onClick = { - dismissAllowed = true - scope.launch { - sheetState.hide() - if (share.shareState == ShareState.DRAFT) onDismissDraft(share) else viewModel.setActiveShare( - null - ) - } + scope.launch { sheetState.hide() }.invokeOnCompletion { dismissSheet() } }) { Icon( imageVector = Icons.Default.Close, diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt index 442af66f..a5794ae9 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt @@ -70,6 +70,8 @@ class ShareViewModel( private val _isPreparingLink = MutableStateFlow(false) val isPreparingLink: StateFlow = _isPreparingLink + private val isCreatingDraft = MutableStateFlow(false) + private val _activeShare = MutableStateFlow(ActiveShareState.None) val activeShare: StateFlow = _activeShare @@ -183,17 +185,23 @@ class ShareViewModel( // region create fun createDraftShare() { + if (!isCreatingDraft.compareAndSet(expect = false, update = true)) return + viewModelScope.launch(Dispatchers.IO) { - _errorMessageId.update { null } + try { + _errorMessageId.update { null } - val result = repository.createDraftShare() - val draft = result.dataOrElse { _errorMessageId.update { R.string.share_view_create_error_message } } - ?: return@launch + val result = repository.createDraftShare() + val draft = result.dataOrElse { _errorMessageId.update { R.string.share_view_create_error_message } } + ?: return@launch - updateEditorEntry(ShareEditorEntry.EDIT) - updateActiveShare(draft.toActiveShare()) + updateEditorEntry(ShareEditorEntry.EDIT) + updateActiveShare(draft.toActiveShare()) - applySource(draft.id, sourceId) + applySource(draft.id, sourceId) + } finally { + isCreatingDraft.value = false + } } } // endregion @@ -396,6 +404,16 @@ class ShareViewModel( _errorMessageId.update { value } } + fun dismissActiveShare(id: String) { + val active = _activeShare.value.shareOrNull ?: return + if (active.id != id) return + + if (active.shareState == ShareState.DRAFT) { + deleteShare(id) + } + setActiveShare(null) + } + fun setActiveShare(value: Share?, entry: ShareEditorEntry = ShareEditorEntry.EDIT) { _propertyErrors.update { emptyMap() } _pendingProperties.update { emptySet() } From 1ee589e9692034384799cdbcf13cb7be7e2fd29e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 21 Aug 2026 12:51:27 +0200 Subject: [PATCH 144/144] wip Signed-off-by: alperozturk96 --- .../android/common/ui/share/ShareScreen.kt | 186 ++++++++++-------- .../bottomsheet/AddOrEditShareBottomSheet.kt | 149 +++++++------- .../component/property/SharePropertyView.kt | 42 ++-- .../share/model/ui/ShareItemOverlayState.kt | 15 -- .../common/ui/share/model/ui/ShareOverlay.kt | 45 +++++ .../ui/share/model/ui/ShareScreenState.kt | 7 +- .../ui/share/viewmodel/ShareViewModel.kt | 101 ++++++---- ui/src/main/res/values/strings.xml | 1 + 8 files changed, 307 insertions(+), 239 deletions(-) delete mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemOverlayState.kt create mode 100644 ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareOverlay.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 0de17b3c..7507f6d7 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 @@ -88,8 +88,8 @@ import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPres 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.ShareEditorEntry -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.ShareOverlay import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState import com.nextcloud.android.common.ui.share.model.ui.label import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository @@ -110,6 +110,9 @@ private fun ShareScreen( val permissionPresets by viewModel.permissionPresets.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val resources = LocalResources.current + var overlay by rememberSaveable(stateSaver = ShareOverlay.Saver) { + mutableStateOf(ShareOverlay.None) + } LaunchedEffect(errorMessageId) { errorMessageId?.let { @@ -130,31 +133,21 @@ private fun ShareScreen( containerColor = Color.Transparent ) { paddingValues -> when (val state = screenState) { - is ShareScreenState.Loading -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } + is ShareScreenState.Loading -> Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } - is ShareScreenState.Empty -> ShareList( - isRefreshing = false, - shares = emptyList(), + else -> ShareList( + state = state, permissionPresets = permissionPresets, paddingValues = paddingValues, - viewModel = viewModel - ) - - is ShareScreenState.Loaded -> ShareList( - isRefreshing = state.refreshing, - shares = state.shares, - permissionPresets = permissionPresets, - paddingValues = paddingValues, - viewModel = viewModel + viewModel = viewModel, + onShowOverlay = { overlay = it } ) } } @@ -167,18 +160,66 @@ private fun ShareScreen( permissionPresets = permissionPresets ) } + + ShareItemOverlay( + overlay = overlay, + shares = (screenState as? ShareScreenState.Loaded)?.shares.orEmpty(), + permissionPresets = permissionPresets, + onDismiss = { overlay = ShareOverlay.None }, + viewModel = viewModel + ) +} + +@Composable +private fun ShareItemOverlay( + overlay: ShareOverlay, + shares: List, + permissionPresets: List, + onDismiss: () -> Unit, + viewModel: ShareViewModel +) { + val share = overlay.shareId?.let { id -> shares.firstOrNull { it.id == id } } ?: return + + when (overlay) { + is ShareOverlay.QuickShare -> QuickSharePermissionBottomSheet( + options = PermissionPresetOption.optionsFor(share, permissionPresets), + selectedOption = PermissionPresetOption.from(share.permissionPreset, permissionPresets), + onOptionSelected = { option -> + onDismiss() + val presetClass = option.presetClass + if (presetClass != null) { + viewModel.updatePermissionPreset(share.id, presetClass, updateActiveShare = false) + } else { + viewModel.setActiveShare(share, ShareEditorEntry.CUSTOMIZE_PERMISSION) + } + }, + onDismiss = onDismiss + ) + + is ShareOverlay.DeleteConfirmation -> DeleteShareConfirmationDialog( + onConfirm = { + onDismiss() + viewModel.deleteShare(share.id) + }, + onDismiss = onDismiss + ) + + ShareOverlay.None -> Unit + } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ShareList( - isRefreshing: Boolean, - shares: List, + state: ShareScreenState, permissionPresets: List, paddingValues: PaddingValues, - viewModel: ShareViewModel + viewModel: ShareViewModel, + onShowOverlay: (ShareOverlay) -> Unit ) { val context = LocalContext.current + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val shares = (state as? ShareScreenState.Loaded)?.shares.orEmpty() PullToRefreshBox( isRefreshing = isRefreshing, @@ -194,10 +235,7 @@ private fun ShareList( if (shares.isEmpty()) { item { Box(modifier = Modifier.fillParentMaxSize()) { - ContentUnavailableView( - iconId = R.drawable.ic_person_add, - title = stringResource(R.string.share_view_empty_title) - ) + SharePlaceholder(state) } } } @@ -209,73 +247,50 @@ private fun ShareList( share = share, title = share.getHeadline(context, shares), type = ShareItemType.type(index, shares.lastIndex), - permissionPresets = permissionPresets, + presetOptions = PermissionPresetOption.optionsFor(share, permissionPresets), + selectedPresetOption = PermissionPresetOption.from(share.permissionPreset, permissionPresets), onSelectShare = { selected -> viewModel.setActiveShare(selected, ShareEditorEntry.EDIT) }, - onCustomizeShare = { selected -> - viewModel.setActiveShare(selected, ShareEditorEntry.CUSTOMIZE_PERMISSION) - }, - onChangePreset = { selected, preset -> - viewModel.updatePermissionPreset(selected.id, preset, updateActiveShare = false) - }, - onDeleteShare = { viewModel.deleteShare(it.id) }, onSendEmail = { selected -> viewModel.setActiveShare(selected, ShareEditorEntry.SEND_EMAIL) - } + }, + onShowOverlay = onShowOverlay ) } } } } +@Composable +private fun SharePlaceholder(state: ShareScreenState) { + if (state is ShareScreenState.Error) { + ContentUnavailableView( + title = stringResource(R.string.share_view_fetch_error_message), + description = stringResource(R.string.share_view_fetch_error_description) + ) + return + } + + ContentUnavailableView( + iconId = R.drawable.ic_person_add, + title = stringResource(R.string.share_view_empty_title) + ) +} + @Composable private fun ShareItem( share: Share, title: String, type: ShareItemType, - permissionPresets: List, + presetOptions: List, + selectedPresetOption: PermissionPresetOption, onSelectShare: (Share) -> Unit, - onCustomizeShare: (Share) -> Unit, - onChangePreset: (Share, String) -> Unit, - onDeleteShare: (Share) -> Unit, - onSendEmail: (Share) -> Unit + onSendEmail: (Share) -> Unit, + onShowOverlay: (ShareOverlay) -> Unit ) { - var overlayState by rememberSaveable(share.id) { mutableStateOf(ShareItemOverlayState.None) } + var contextMenuExpanded by remember { mutableStateOf(false) } val haptics = LocalHapticFeedback.current - val presetOptions = PermissionPresetOption.optionsFor(share, permissionPresets) - val selectedPresetOption = PermissionPresetOption.from(share.permissionPreset, permissionPresets) - - when (overlayState) { - ShareItemOverlayState.QuickShare -> { - QuickSharePermissionBottomSheet( - options = presetOptions, - selectedOption = selectedPresetOption, - onOptionSelected = { option -> - overlayState = ShareItemOverlayState.None - val presetClass = option.presetClass - if (presetClass != null) { - onChangePreset(share, presetClass) - } else { - onCustomizeShare(share) - } - }, - onDismiss = { overlayState = ShareItemOverlayState.None } - ) - } - - ShareItemOverlayState.DeleteConfirmation -> { - DeleteShareConfirmationDialog( - onConfirm = { - overlayState = ShareItemOverlayState.None - onDeleteShare(share) - }, - onDismiss = { overlayState = ShareItemOverlayState.None } - ) - } - - else -> Unit - } ListItem( modifier = Modifier @@ -285,7 +300,7 @@ private fun ShareItem( onClick = { onSelectShare(share) }, onLongClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) - overlayState = ShareItemOverlayState.ContextMenu + contextMenuExpanded = true } ) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), @@ -318,7 +333,7 @@ private fun ShareItem( modifier = Modifier .offset(x = -chipHorizontalPadding) .clip(RoundedCornerShape(percent = 50)) - .clickable { overlayState = ShareItemOverlayState.QuickShare } + .clickable { onShowOverlay(ShareOverlay.QuickShare(share.id)) } .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)) .padding(horizontal = chipHorizontalPadding, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically @@ -355,13 +370,13 @@ private fun ShareItem( }, trailingContent = { Box { - IconButton(onClick = { overlayState = ShareItemOverlayState.ContextMenu }) { + IconButton(onClick = { contextMenuExpanded = true }) { Icon(Icons.Default.MoreVert, contentDescription = "More options") } DropdownMenu( - expanded = overlayState == ShareItemOverlayState.ContextMenu, - onDismissRequest = { overlayState = ShareItemOverlayState.None } + expanded = contextMenuExpanded, + onDismissRequest = { contextMenuExpanded = false } ) { DropdownMenuItem( leadingIcon = { @@ -372,7 +387,7 @@ private fun ShareItem( }, text = { Text(stringResource(R.string.share_view_list_item_edit)) }, onClick = { - overlayState = ShareItemOverlayState.None + contextMenuExpanded = false onSelectShare(share) } ) @@ -386,8 +401,8 @@ private fun ShareItem( }, text = { Text(stringResource(R.string.share_view_list_item_send_email)) }, onClick = { + contextMenuExpanded = false onSendEmail(share) - overlayState = ShareItemOverlayState.None } ) HorizontalDivider() @@ -406,7 +421,8 @@ private fun ShareItem( ) }, onClick = { - overlayState = ShareItemOverlayState.DeleteConfirmation + contextMenuExpanded = false + onShowOverlay(ShareOverlay.DeleteConfirmation(share.id)) } ) } 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 3dd8386a..73a4ebb7 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,7 +11,6 @@ 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 @@ -67,6 +66,7 @@ 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 +import com.nextcloud.android.common.ui.share.component.property.propertyErrorMessage 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 @@ -116,92 +116,89 @@ fun AddOrEditShareBottomSheet( dismissSheet() } - Column( - modifier = Modifier - .fillMaxSize() + ModalBottomSheet( + onDismissRequest = dismissSheet, + sheetState = sheetState, + sheetGesturesEnabled = false, + dragHandle = null, + properties = ModalBottomSheetProperties(shouldDismissOnClickOutside = false), + containerColor = MaterialTheme.colorScheme.surface, ) { - ModalBottomSheet( - onDismissRequest = dismissSheet, - sheetState = sheetState, - sheetGesturesEnabled = false, - dragHandle = null, - properties = ModalBottomSheetProperties(shouldDismissOnClickOutside = false), - containerColor = MaterialTheme.colorScheme.surface, + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) ) { - Column( + Row( modifier = Modifier .fillMaxWidth() - .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = share.title(context), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.MiddleEllipsis, - modifier = Modifier.weight(1f) + Text( + text = share.title(context), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f) + ) + + IconButton(onClick = { + scope.launch { sheetState.hide() }.invokeOnCompletion { dismissSheet() } + }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "close bottom sheet", + tint = MaterialTheme.colorScheme.onSurfaceVariant ) + } + } - IconButton(onClick = { - scope.launch { sheetState.hide() }.invokeOnCompletion { dismissSheet() } - }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "close bottom sheet", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + ShareCategorySelector( + share = share, + categories = categories, + selectedCategory = selectedCategory, + onCategorySelected = { category -> + selectedCategory = category + viewModel.selectCategory(category, share) } + ) - ShareCategorySelector( - share = share, - categories = categories, - selectedCategory = selectedCategory, - onCategorySelected = { category -> - selectedCategory = category - viewModel.selectCategory(category, share) - } - ) + if (selectedCategory == ShareCategory.Invited) { + SelectRecipientField(share, viewModel) + } - if (selectedCategory == ShareCategory.Invited) { - SelectRecipientField(share, viewModel) - } + PermissionsView( + share = share, + entry = entry, + permissionPresets = permissionPresets, + viewModel = viewModel + ) - PermissionsView( - share = share, - entry = entry, - permissionPresets = permissionPresets, - viewModel = viewModel - ) + BasicSettingsSection( + share = share, + propertyErrors = propertyErrors, + viewModel = viewModel + ) - BasicSettingsSection( - share = share, - viewModel = viewModel - ) + AdvancedSettingsSection( + share = share, + isExpanded = showAdvancedSettings, + onToggle = { showAdvancedSettings = !showAdvancedSettings }, + propertyErrors = propertyErrors, + viewModel = viewModel + ) - AdvancedSettingsSection( + if (share.canSend) { + ActionButtons( share = share, - isExpanded = showAdvancedSettings, - onToggle = { showAdvancedSettings = !showAdvancedSettings }, + internalLink = internalLink, + category = selectedCategory, + sendEnabled = sendEnabled, viewModel = viewModel ) - - if (share.canSend) { - ActionButtons( - share = share, - internalLink = internalLink, - category = selectedCategory, - sendEnabled = sendEnabled, - viewModel = viewModel - ) - } } } } @@ -331,6 +328,7 @@ private fun PermissionPresetDropdown( @Composable private fun BasicSettingsSection( share: Share, + propertyErrors: Map, viewModel: ShareViewModel ) { if (!share.isBasicSectionAvailable) { @@ -345,9 +343,9 @@ private fun BasicSettingsSection( share.basicProperties.forEach { property -> key(property.clazz) { SharePropertyView( - shareId = share.id, property = property, - viewModel = viewModel + errorMessage = propertyErrorMessage(propertyErrors, property.clazz), + onValueChange = { value -> viewModel.updateProperty(share.id, property.clazz, value) } ) } } @@ -359,6 +357,7 @@ private fun AdvancedSettingsSection( share: Share, isExpanded: Boolean, onToggle: () -> Unit, + propertyErrors: Map, viewModel: ShareViewModel ) { if (!share.isAdvancedSectionAvailable) { @@ -373,9 +372,9 @@ private fun AdvancedSettingsSection( share.advancedProperties.forEach { property -> key(property.clazz) { SharePropertyView( - shareId = share.id, property = property, - viewModel = viewModel + errorMessage = propertyErrorMessage(propertyErrors, property.clazz), + onValueChange = { value -> viewModel.updateProperty(share.id, property.clazz, 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 6a1a3173..689eb4f6 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 @@ -23,9 +23,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nextcloud.android.common.ui.R -import com.nextcloud.android.common.ui.share.viewmodel.ShareViewModel 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 @@ -40,21 +38,23 @@ private const val MULTILINE_MIN_LINES = 1 private const val MULTILINE_MAX_LINES = 3 @Composable -fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewModel) { - val propertyErrors by viewModel.propertyErrors.collectAsStateWithLifecycle() +fun propertyErrorMessage(propertyErrors: Map, clazz: String): String? { val fallbackError = stringResource(R.string.share_view_update_error_message) - val errorMessage = if (propertyErrors.containsKey(property.clazz)) { - propertyErrors[property.clazz] ?: fallbackError - } else { - null - } + if (!propertyErrors.containsKey(clazz)) return null + return propertyErrors[clazz] ?: fallbackError +} +@Composable +fun SharePropertyView( + property: Property, + errorMessage: String?, + onValueChange: (String?) -> Unit +) { Column { SharePropertyField( - shareId = shareId, property = property, isError = errorMessage != null, - viewModel = viewModel + onValueChange = onValueChange ) SharePropertyMessage(errorMessage = errorMessage, hint = property.hint) @@ -63,10 +63,9 @@ fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewM @Composable private fun SharePropertyField( - shareId: String, property: Property, isError: Boolean, - viewModel: ShareViewModel + onValueChange: (String?) -> Unit ) { when (property) { is PropertyBoolean -> { @@ -77,46 +76,43 @@ private fun SharePropertyField( checked = checkedValue, onCheckedChange = { isChecked -> checkedValue = isChecked - viewModel.updateProperty(shareId, property.clazz, isChecked.toString()) + onValueChange(isChecked.toString()) } ) } is PropertyString -> ShareTextPropertyField( - shareId = shareId, property = property, isError = isError, - viewModel = viewModel + onValueChange = onValueChange ) is PropertyPassword -> ShareTextPropertyField( - shareId = shareId, property = property, isError = isError, - viewModel = viewModel, + onValueChange = onValueChange, visualTransformation = PasswordVisualTransformation() ) is PropertyDate -> ShareDatePicker( property = property, isError = isError, - onDateSelected = { dateValue -> viewModel.updateProperty(shareId, property.clazz, dateValue) } + onDateSelected = onValueChange ) is PropertyEnum -> SharePropertyEnumField( property = property, isError = isError, - onValueSelected = { value -> viewModel.updateProperty(shareId, property.clazz, value) } + onValueSelected = onValueChange ) } } @Composable private fun ShareTextPropertyField( - shareId: String, property: Property, isError: Boolean, - viewModel: ShareViewModel, + onValueChange: (String?) -> Unit, visualTransformation: VisualTransformation = VisualTransformation.None ) { val stringProperty = property as? PropertyString @@ -144,7 +140,7 @@ private fun ShareTextPropertyField( .onFocusChanged { focusState -> if (wasFocused && !focusState.isFocused && value != committedValue) { committedValue = value - viewModel.updateProperty(shareId, clazz, value) + onValueChange(value) } wasFocused = focusState.isFocused }, 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 deleted file mode 100644 index b5c73b97..00000000 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemOverlayState.kt +++ /dev/null @@ -1,15 +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.model.ui - -enum class ShareItemOverlayState { - None, - ContextMenu, - QuickShare, - DeleteConfirmation -} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareOverlay.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareOverlay.kt new file mode 100644 index 00000000..92242a11 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareOverlay.kt @@ -0,0 +1,45 @@ +/* + * 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.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver + +sealed class ShareOverlay { + abstract val shareId: String? + + data object None : ShareOverlay() { + override val shareId: String? = null + } + + data class QuickShare(override val shareId: String) : ShareOverlay() + data class DeleteConfirmation(override val shareId: String) : ShareOverlay() + + companion object { + private const val QUICK_SHARE = "quick_share" + private const val DELETE_CONFIRMATION = "delete_confirmation" + + val Saver: Saver = listSaver( + save = { overlay -> + when (overlay) { + None -> emptyList() + is QuickShare -> listOf(QUICK_SHARE, overlay.shareId) + is DeleteConfirmation -> listOf(DELETE_CONFIRMATION, overlay.shareId) + } + }, + restore = { saved -> + val shareId = saved.getOrNull(1) + when (saved.firstOrNull()) { + QUICK_SHARE -> shareId?.let(::QuickShare) + DELETE_CONFIRMATION -> shareId?.let(::DeleteConfirmation) + else -> None + } ?: None + } + ) + } +} 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 81fb4038..fdaa7111 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 @@ -11,9 +11,10 @@ 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, val refreshing: Boolean): ShareScreenState() + data object Loading : ShareScreenState() + data object Empty : ShareScreenState() + data object Error : ShareScreenState() + data class Loaded(val shares: List) : ShareScreenState() } fun List.activeOnly(): List = filter { it.shareState == ShareState.ACTIVE } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt index a5794ae9..7225726d 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt @@ -31,13 +31,15 @@ import com.nextcloud.android.common.ui.share.model.ui.ShareEditorEntry import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState import com.nextcloud.android.common.ui.share.model.ui.activeOnly import com.nextcloud.android.common.ui.share.repository.ShareRepository -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -45,7 +47,6 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.util.UUID import kotlin.time.Duration.Companion.milliseconds @@ -65,30 +66,33 @@ class ShareViewModel( private val savedState = ShareSavedState(savedStateHandle) private val _state = MutableStateFlow(ShareScreenState.Loading) - val state: StateFlow = _state + val state: StateFlow = _state.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() private val _isPreparingLink = MutableStateFlow(false) - val isPreparingLink: StateFlow = _isPreparingLink + val isPreparingLink: StateFlow = _isPreparingLink.asStateFlow() private val isCreatingDraft = MutableStateFlow(false) private val _activeShare = MutableStateFlow(ActiveShareState.None) - val activeShare: StateFlow = _activeShare + val activeShare: StateFlow = _activeShare.asStateFlow() private val _editorEntry = MutableStateFlow(savedState.editorEntry) - val editorEntry: StateFlow = _editorEntry + val editorEntry: StateFlow = _editorEntry.asStateFlow() private val _permissionPresets = MutableStateFlow>(emptyList()) - val permissionPresets: StateFlow> = _permissionPresets + val permissionPresets: StateFlow> = _permissionPresets.asStateFlow() private val _errorMessageId = MutableStateFlow(null) - val errorMessageId: StateFlow = _errorMessageId + val errorMessageId: StateFlow = _errorMessageId.asStateFlow() private val _propertyErrors = MutableStateFlow>(emptyMap()) - val propertyErrors: StateFlow> = _propertyErrors + val propertyErrors: StateFlow> = _propertyErrors.asStateFlow() private val _pendingProperties = MutableStateFlow>(emptySet()) - val pendingProperties: StateFlow> = _pendingProperties + val pendingProperties: StateFlow> = _pendingProperties.asStateFlow() private val currentShares: List get() = (_state.value as? ShareScreenState.Loaded)?.shares ?: emptyList() @@ -111,8 +115,15 @@ class ShareViewModel( loadInitialData() } + override fun onCleared() { + super.onCleared() + + val draft = _activeShare.value.shareOrNull?.takeIf { it.shareState == ShareState.DRAFT } ?: return + CoroutineScope(SupervisorJob()).launch { repository.deleteShare(draft.id) } + } + private fun loadInitialData() { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { launch { loadSharingCapabilities() } launch { restoreActiveShare() } loadShares() @@ -155,19 +166,14 @@ class ShareViewModel( // region shares list fun refreshShares() { - val state = _state.value as? ShareScreenState.Loaded ?: return - if (state.refreshing) return + if (!_isRefreshing.compareAndSet(expect = false, update = true)) return - viewModelScope.launch(Dispatchers.IO) { - setRefreshing(true) - loadShares() - setRefreshing(false) - } - } - - private fun setRefreshing(refreshing: Boolean) { - _state.update { - if (it is ShareScreenState.Loaded) it.copy(refreshing = refreshing) else it + viewModelScope.launch { + try { + loadShares() + } finally { + _isRefreshing.value = false + } } } @@ -177,8 +183,26 @@ class ShareViewModel( filterSourceTypeValue = sourceId, filterSourceTypeClass = Source.NODE_SOURCE_CLASS ) - result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } }?.let { fetched -> - publishShares(fetched.activeOnly()) + val fetched = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } + + if (fetched == null) { + _state.update { it as? ShareScreenState.Loaded ?: ShareScreenState.Error } + return + } + + publishShares(fetched.activeOnly()) + deleteAbandonedDrafts(fetched) + } + + private fun deleteAbandonedDrafts(fetched: List) { + if (isCreatingDraft.value) return + + val openShareIds = setOfNotNull(_activeShare.value.shareOrNull?.id, savedState.activeShareId) + val abandoned = fetched.filter { it.shareState == ShareState.DRAFT && it.id !in openShareIds } + if (abandoned.isEmpty()) return + + viewModelScope.launch { + abandoned.forEach { repository.deleteShare(it.id) } } } // endregion @@ -187,7 +211,7 @@ class ShareViewModel( fun createDraftShare() { if (!isCreatingDraft.compareAndSet(expect = false, update = true)) return - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { try { _errorMessageId.update { null } @@ -208,7 +232,7 @@ class ShareViewModel( // region state fun updateState(id: String, shareState: ShareState) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { val updated = applyState(id, shareState) ?: return@launch if (shareState == ShareState.ACTIVE) { @@ -223,7 +247,7 @@ class ShareViewModel( fun prepareLinkForCopy(id: String) { if (_isPreparingLink.value) return - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { _isPreparingLink.update { true } val updated = applyState(id, ShareState.ACTIVE) @@ -259,7 +283,7 @@ class ShareViewModel( // region recipients fun addRecipient(id: String, clazz: String, value: String, instance: String? = null) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { val result = repository.addShareRecipient(id, AddRecipientRequest(clazz, value, instance)) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch @@ -295,7 +319,7 @@ class ShareViewModel( } fun removeRecipient(id: String, clazz: String, value: String, instance: String? = null) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { val result = repository.removeShareRecipient(id, clazz, value, instance) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch @@ -306,7 +330,7 @@ class ShareViewModel( fun updateRecipientSecret(shareId: String, recipient: Recipient, secret: String) { secretUpdateJob?.cancel() - secretUpdateJob = viewModelScope.launch(Dispatchers.IO) { + secretUpdateJob = viewModelScope.launch { val request = UpdateShareRecipientSecretRequest( clazz = recipient.clazz, value = recipient.value, @@ -321,10 +345,10 @@ class ShareViewModel( } } - suspend fun generateSecret(): String? = withContext(Dispatchers.IO) { + suspend fun generateSecret(): String? { _errorMessageId.update { null } val result = repository.generateSecret() - result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + return result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } } // endregion @@ -335,7 +359,7 @@ class ShareViewModel( if (value.isNullOrEmpty()) { _propertyErrors.update { it - clazz } } - propertyUpdateJobs[clazz] = viewModelScope.launch(Dispatchers.IO) { + propertyUpdateJobs[clazz] = viewModelScope.launch { when (val result = repository.updateShareProperty(shareId, UpdateSharePropertyRequest(clazz, value))) { is NetworkResult.Success -> { _propertyErrors.update { it - clazz } @@ -362,16 +386,17 @@ class ShareViewModel( // region permissions fun updatePermission(id: String, clazz: String, enabled: Boolean) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { val result = repository.updateSharePermission(id, UpdateSharePermissionRequest(clazz, enabled)) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch + refreshActiveShare(updated.toActiveShare()) replaceInList(updated) } } fun updatePermissionPreset(id: String, presetClass: String, updateActiveShare: Boolean) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { val result = repository.updateSharePermissionPreset(id, UpdateSharePermissionPresetRequest(presetClass)) val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } ?: return@launch @@ -385,7 +410,7 @@ class ShareViewModel( // region delete fun deleteShare(id: String) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch { val result = repository.deleteShare(id) result.dataOrElse { _errorMessageId.update { R.string.share_view_delete_error_message } } ?: return@launch @@ -458,7 +483,7 @@ class ShareViewModel( private fun publishShares(shares: List) { _state.update { - if (shares.isEmpty()) ShareScreenState.Empty else ShareScreenState.Loaded(shares, false) + if (shares.isEmpty()) ShareScreenState.Empty else ShareScreenState.Loaded(shares) } } // endregion diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index c37293eb..7af7bc20 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -39,6 +39,7 @@ This share will be permanently removed. Failed to fetch shares. + Pull down to try again. Failed to delete share. Failed to create share. Failed to update share.