From 19c9da8b56921a48690b143df40a004a61b2d63c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 20 Apr 2026 16:00:01 +0200 Subject: [PATCH 01/40] 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 61256ce9..3e58048c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -329,6 +329,11 @@ + + + + + diff --git a/material-color-utilities/build.gradle b/material-color-utilities/build.gradle index 19996c1f..d1e3b6ac 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 52a7e6aa..2a09ab52 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.05.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 569b3924c03d18b4f47b7a76c6109257ec165a5b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 09:52:07 +0200 Subject: [PATCH 02/40] 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 362f4c3a300e63e4b3d388b87bab6912b677369c Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 10:08:58 +0200 Subject: [PATCH 03/40] 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 38c34f0af29e2b9dc6e557d9952e98118b57c24e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 12:41:47 +0200 Subject: [PATCH 04/40] 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 73869d413b706cf8532094a3ecbbc1f143eca056 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 12:44:48 +0200 Subject: [PATCH 05/40] 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 de4598e77547bd3c389af1d3d3025db8c5d39e06 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 21 Apr 2026 15:25:47 +0200 Subject: [PATCH 06/40] 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 3f72cec36db964550684408c9fbdc6e5aa9a2b84 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 14:02:16 +0200 Subject: [PATCH 07/40] wip Signed-off-by: alperozturk96 --- gradle/verification-metadata.xml | 7 ++ sample/build.gradle | 1 + .../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 +++++++++++++++ 11 files changed, 297 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/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 3e58048c..9623579d 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -96,6 +96,7 @@ + @@ -240,6 +241,7 @@ + @@ -20776,6 +20778,11 @@ + + + + + diff --git a/sample/build.gradle b/sample/build.gradle index e6501918..5c3b135f 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.10.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.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/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 2a09ab52..0db58549 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.05.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 3cc1841b2738877fbc55b0939b34313ee040c4b2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 24 Apr 2026 10:39:10 +0200 Subject: [PATCH 08/40] 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 4e242b80ce639bf245cf9ff53a46dbff92446c81 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 14:11:09 +0200 Subject: [PATCH 09/40] 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 5b7372840d7c5c11d6f82f5f68c4714c551d6625 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 14:56:55 +0200 Subject: [PATCH 10/40] 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 1bb45747ad60a03725039ad7af51a915a43afe33 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 16:16:59 +0200 Subject: [PATCH 11/40] 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 6a76774bb9fafd3dc9939e292afad6562e2551f8 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 16:20:32 +0200 Subject: [PATCH 12/40] 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 0433bf05b71f8ee683a85b5c203f2ccafc113c2e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 16:21:37 +0200 Subject: [PATCH 13/40] 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 cecb3e9f7562701985ab0023142f60ae2622d7c6 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 6 May 2026 08:38:51 +0200 Subject: [PATCH 14/40] 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 f8e9e8947fda009d0e131c7ce843b774b80088e4 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 6 May 2026 08:56:45 +0200 Subject: [PATCH 15/40] 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 66f177cc3d30aa541cb8cf339d077ad70136fb8e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 12:18:47 +0300 Subject: [PATCH 16/40] 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 ce736aadab18eec41d72031bd6d198ccfd18bf8b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 15:54:22 +0300 Subject: [PATCH 17/40] 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 6adec5bb0dcdde5fc80ca56c40aa1084fb4abc16 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 16:16:01 +0300 Subject: [PATCH 18/40] 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 c1aa2ad554c55bedb7d228f87787fbb4bf714e6f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 16:49:33 +0300 Subject: [PATCH 19/40] 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 3f9087ab4db8f2fdc5d3ee8fe2e0ba91ac40eba7 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 17:02:32 +0300 Subject: [PATCH 20/40] 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 6392ed0c5dd47f12806f19308bbba86ef4534927 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 28 May 2026 15:04:34 +0300 Subject: [PATCH 21/40] 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 22f7e36eb879b717175a6cc2dd9ee15c1a35a338 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 28 May 2026 15:48:42 +0300 Subject: [PATCH 22/40] 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 1352224da5555f739a01f97fc16c41c3200aa94e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 28 May 2026 16:02:29 +0300 Subject: [PATCH 23/40] 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 ee8ee78ae34caad6ab32c68fe76397644b3041b3 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 12:40:08 +0300 Subject: [PATCH 24/40] 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 ab810bcce49ea13de6a7987d4427cf1e112be5ed Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 12:46:17 +0300 Subject: [PATCH 25/40] 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 948e34d8..ceffd484 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.18.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 00509873972ec1436ce918cd2ef1891b85214a64 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 13:40:44 +0300 Subject: [PATCH 26/40] 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 76e624d772015b9e098ab3b64bc695a01a714d7b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 15:27:41 +0300 Subject: [PATCH 27/40] 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 408f0e8fe3fa09cd65648332a8a60f157ad72d72 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 15:43:15 +0300 Subject: [PATCH 28/40] 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 3d60ff3a5a9d00b498c170753a9adfa3c62164ee Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 16:12:05 +0300 Subject: [PATCH 29/40] wip Signed-off-by: alperozturk96 --- ui/build.gradle | 2 +- .../android/common/ui/share/ShareView.kt | 83 +++++++++++++++---- ui/src/main/res/drawable/ic_calendar.xml | 24 ++++++ ui/src/main/res/values/strings.xml | 3 + 4 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 ui/src/main/res/drawable/ic_calendar.xml diff --git a/ui/build.gradle b/ui/build.gradle index ceffd484..07297dd8 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -16,7 +16,7 @@ plugins { android { defaultConfig { - minSdk = 21 + minSdk = 26 compileSdk = 36 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 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 46c04be3348ec506bfccfcf49b7acf992c644bf5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 29 May 2026 16:12:30 +0300 Subject: [PATCH 30/40] 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 a7fbfab43ee9b479ac22725a0c10f281a0e59eab Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 08:48:43 +0300 Subject: [PATCH 31/40] 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 ff1afc467ed0384823811a1e35bc19df68abfd69 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 08:49:41 +0300 Subject: [PATCH 32/40] 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 4cdfa976a1441945deacc4afc9ccdfae809cd655 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 08:59:44 +0300 Subject: [PATCH 33/40] 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 872e439f5d4a6b874ff02bbcd8b3b0a2c9e1d85d Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 09:11:06 +0300 Subject: [PATCH 34/40] 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 1f6dce730f577e5ff14f1dac621178bc8a106e27 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 09:16:03 +0300 Subject: [PATCH 35/40] 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 6d5f21795f40be682ed6e4642650b602f4b581d0 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 09:52:27 +0300 Subject: [PATCH 36/40] 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 4f6e597d5a3fc8cb0308aacc726c6188ef2905d4 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 10:10:20 +0300 Subject: [PATCH 37/40] 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 e8b64c4ac46142f4084552a2ea7f77be0a22107f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 10:22:06 +0300 Subject: [PATCH 38/40] 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 d1ef5d0c4d67cc1622ca526045c0344f66130877 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 10:29:27 +0300 Subject: [PATCH 39/40] 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 720ad2081a213e32395e5146a7538b7ae5e623ad Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Mon, 1 Jun 2026 10:30:12 +0300 Subject: [PATCH 40/40] 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