diff --git a/build.gradle b/build.gradle index 16cff00e..972590d4 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,7 @@ buildscript { // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'org.jetbrains.kotlin.plugin.compose' version '2.3.20' apply false + id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlinVersion" apply false id 'com.android.library' version '9.1.1' apply false id 'org.jetbrains.kotlin.android' version "$kotlinVersion" apply false id 'com.android.legacy-kapt' version '9.1.1' apply false diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a225095f..bfe8235f 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -92,6 +92,7 @@ + @@ -236,6 +237,7 @@ + @@ -324,6 +326,11 @@ + + + + + @@ -17849,6 +17856,11 @@ + + + + + @@ -19234,6 +19246,11 @@ + + + + + @@ -20699,6 +20716,11 @@ + + + + + @@ -21402,6 +21424,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/sample/build.gradle b/sample/build.gradle index c35d96aa..e06fc6ad 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..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 @@ -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.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 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 = ApiHttpClient.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 1c11fea6..45b88a2a 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' id 'com.android.library' id 'com.android.built-in-kotlin' id 'com.android.legacy-kapt' @@ -45,12 +46,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.03.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") @@ -60,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.7.3") + implementation project(':core') api project(':material-color-utilities') 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/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..9fa314ee --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusService.kt @@ -0,0 +1,66 @@ +/* + * 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().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" + ) + ) + ) + } + } +} 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/api/ApiCredentials.kt new file mode 100644 index 00000000..f6770090 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiCredentials.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.api + +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/api/ApiHttpClient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.kt new file mode 100644 index 00000000..499207a8 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/api/ApiHttpClient.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.api + +import okhttp3.Credentials +import okhttp3.Interceptor +import okhttp3.OkHttpClient +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 = 30L + private const val READ_TIMEOUT_SECONDS = 30L + private const val WRITE_TIMEOUT_SECONDS = 30L + + 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) + } + } + + 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/model/ApiResult.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/ApiResult.kt new file mode 100644 index 00000000..9ab9328b --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/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.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/OcsResponse.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt new file mode 100644 index 00000000..d0c361e5 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/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.model + +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/ShareView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt new file mode 100644 index 00000000..6a2c51ea --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareView.kt @@ -0,0 +1,738 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +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 +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.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 +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.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 +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 + + +@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 context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(errorMessageId) { + errorMessageId?.let { + snackbarHostState.showSnackbar(context.getString(it)) + viewModel.updateErrorMessage(null) + } + } + + 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 + } + } + + UnifiedSharesListItem(share, type, onSelectShare = { share -> + bottomSheetState = ShareBottomSheetState.Edit(share) + }, onDeleteShare = { + viewModel.delete(share) + }, 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 + } +} + +// 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, + 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) + ) + } + + 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) + ) { + Text( + text = title, + 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) + } + } 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) } + ) + } + } + + CollapsibleSettingsSection( + isExpanded = showAnyoneSettings, + onToggle = { showAnyoneSettings = !showAnyoneSettings } + ) { + AnyoneInlineSettings(share) + } + } + + 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 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 = stringResource(R.string.share_view_advanced_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() + } + } + } +} + +@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( + 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 +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) { + 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 +private fun UnifiedSharesListItem( + share: UnifiedShare, + type: UnifiedSharesListItemType, + onSelectShare: (UnifiedShare) -> Unit, + onDeleteShare: (UnifiedShare) -> Unit, + onSendEmail: (UnifiedShare) -> Unit +) { + var showContextMenu by remember { mutableStateOf(false) } + val haptics = LocalHapticFeedback.current + + ListItem( + modifier = Modifier + .fillMaxWidth() + .clip(type.getShape()) + .combinedClickable( + onClick = { onSelectShare(share) }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + showContextMenu = true + }, + ) + .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, + style = MaterialTheme.typography.titleSmall + ) + }, + supportingContent = { + share.permission?.getTextId()?.let { + Text( + text = stringResource(it), + 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(stringResource(R.string.share_view_list_item_edit)) }, + onClick = { + showContextMenu = false + onSelectShare(share) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.share_view_list_item_send_email)) }, + onClick = { + onSendEmail(share) + showContextMenu = false + } + ) + + HorizontalDivider() + + DropdownMenuItem( + text = { + Text( + stringResource(R.string.share_view_list_item_delete), + color = MaterialTheme.colorScheme.error + ) + }, + onClick = { + onDeleteShare(share) + showContextMenu = false + } + ) + } + } + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent + ) + ) +} + +@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) + } +} + +fun ComposeView.setupUnifiedShare(colorScheme: ColorScheme) { + // TODO: REPLACE + val viewModel = ShareViewModel(repository = MockShareRepository()) + + setContent { + MaterialTheme( + colorScheme = colorScheme, + content = { + 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 new file mode 100644 index 00000000..5621326e --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -0,0 +1,97 @@ +/* + * 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.R +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 +import kotlinx.coroutines.flow.MutableStateFlow +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 + + private val _loading = MutableStateFlow(false) + val loading: StateFlow = _loading + + private val _errorMessageId = MutableStateFlow(null) + val errorMessageId: StateFlow = _errorMessageId + + init { + loadShares() + } + + // region private methods + private fun loadShares() { + viewModelScope.launch(Dispatchers.IO) { + _loading.value = true + _errorMessageId.value = null + + when (val result = repository.fetchShares()) { + is ApiResult.Success -> { + _shares.update { result.data } + } + + is ApiResult.Error -> { + _errorMessageId.value = R.string.share_view_fetch_error_message + } + } + + _loading.value = false + } + } + // 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 + 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/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..1d2ec2f2 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/create/ShareDataResponse.kt @@ -0,0 +1,50 @@ +/* + * 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/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/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/UnifiedShareCategory.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareCategory.kt new file mode 100644 index 00000000..e93bd3d4 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/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.ui + +enum class UnifiedShareCategory { + Invited, Anyone +} 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 new file mode 100644 index 00000000..46a600be --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/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.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 new file mode 100644 index 00000000..b6511314 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedSharePermission.kt @@ -0,0 +1,79 @@ +/* + * 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 new file mode 100644 index 00000000..ec3a8acc --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShareType.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.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 new file mode 100644 index 00000000..5690cb5f --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/UnifiedShares.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.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 + +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 + ) + } + } +} 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 new file mode 100644 index 00000000..053d951c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -0,0 +1,281 @@ +/* + * 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.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 +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" + ), + + 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 + ) + ), + + 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" + ), + + permission = UnifiedSharePermission.CanEdit, + label = "Marketing Team", + note = "", + password = "", + type = UnifiedShareType.InternalGroup, + category = UnifiedShareCategory.Invited, + limit = UnifiedShareDownloadLimit( + limit = 0, + downloadCount = 0 + ) + ), + + 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" + ), + + 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 + ) + ), + + 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" + ), + + permission = UnifiedSharePermission.CanView, + label = "John External", + note = "External partner access", + password = "", + type = UnifiedShareType.ExternalMail, + category = UnifiedShareCategory.Anyone, + limit = UnifiedShareDownloadLimit( + limit = 20, + downloadCount = 2 + ) + ), + + 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" + ), + + 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 + ) + ) + ) + + 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 new file mode 100644 index 00000000..6551f27f --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -0,0 +1,92 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.repository + +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 +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 + + /** + * 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..3267d9db --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.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.share.repository + +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 +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( + 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/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 @@ + + + + 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