diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 61256ce9..9623579d 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -96,6 +96,7 @@ + @@ -240,6 +241,7 @@ + @@ -329,6 +331,11 @@ + + + + + @@ -20771,6 +20778,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 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/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"> + 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..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 @@ -9,7 +9,41 @@ 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.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() { val color = MutableLiveData() + val apiTestResult = MutableLiveData() + + fun testPredefinedStatuses( + baseUrl: String, + username: String, + token: String + ) { + viewModelScope.launch { + val credentials = ServerCredentials(baseUrl, username, token) + val client = NextcloudHttpClient.create(credentials, enableLogging = true) + val service = UserStatusRepository(client) + + when (val result = service.fetchPredefinedStatuses()) { + is NetworkResult.Success -> + apiTestResult.value = + "✅ Success (${result.data.size} statuses):\n" + + result.data.joinToString("\n") { "${it.icon} ${it.message}" } + + is NetworkResult.ServerError -> + apiTestResult.value = + "❌ Error ${result.response.ocs.meta.statusCode}: ${result.response.ocs.meta.message}" + + is NetworkResult.NetworkException -> + apiTestResult.value = + "❌ Exception: ${result.throwable.message}" + } + } + } } 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 52a7e6aa..07297dd8 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" id 'com.android.library' id 'com.android.built-in-kotlin' id 'com.android.legacy-kapt' @@ -15,7 +16,7 @@ plugins { android { defaultConfig { - minSdk = 21 + minSdk = 26 compileSdk = 36 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -45,12 +46,19 @@ android { } dependencies { + 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")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") 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") @@ -60,6 +68,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/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/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/network/PredefinedStatus.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/PredefinedStatus.kt new file mode 100644 index 00000000..bc1eb79b --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/PredefinedStatus.kt @@ -0,0 +1,43 @@ +/* + * 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.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive + +object ClearAtTimeSerializer : KSerializer { + 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, + @Serializable(with = ClearAtTimeSerializer::class) + val time: String +) + +@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/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/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/auth/ServerCredentials.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/ServerCredentials.kt new file mode 100644 index 00000000..b299353c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/ServerCredentials.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.auth + +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/http/HttpMethod.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/HttpMethod.kt new file mode 100644 index 00000000..f5901b8d --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/HttpMethod.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.http + +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/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/model/OcsResponse.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt new file mode 100644 index 00000000..0b5c39db --- /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/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/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt new file mode 100644 index 00000000..2fd80eef --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -0,0 +1,297 @@ +/* + * 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.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 +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 +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.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.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.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil3.ImageLoader +import coil3.compose.setSingletonImageLoaderFactory +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.bottomsheet.AddOrEditShareBottomSheet +import com.nextcloud.android.common.ui.share.component.dialog.DeleteShareConfirmationDialog +import com.nextcloud.android.common.ui.share.component.dialog.DiscardDraftShareDialog +import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities +import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.ui.ShareItemOverlayState +import com.nextcloud.android.common.ui.share.model.ui.ShareItemType +import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState +import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +@Composable +private fun ShareScreen(sourceId: String, sharingCapabilities: SharingCapabilities, viewModel: ShareViewModel) { + val errorMessageId by viewModel.errorMessageId.collectAsState() + val screenState by viewModel.state.collectAsState() + val activeShare by viewModel.activeShare.collectAsState() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + 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 { + snackbarHostState.showSnackbar(context.getString(it)) + viewModel.updateErrorMessage(null) + } + } + + Scaffold( + floatingActionButton = { + FloatingActionButton( + onClick = { + scope.launch { + viewModel.createDraftShare()?.let { + viewModel.addSource(it.id, sourceId) + } + } + }, + ) { + Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = Color.Transparent + ) { paddingValues -> + 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)) + } else { + Spacer(modifier = Modifier.height(2.dp)) + } + + ShareItem( + share = share, + type = type, + onSelectShare = { selected -> viewModel.setActiveShare(selected) }, + onDeleteShare = { viewModel.deleteShare(it.id) }, + onSendEmail = { } + ) + } + } + } + } + } + + activeShare?.let { + AddOrEditShareBottomSheet( + share = it, + sharingCapabilities = sharingCapabilities, + viewModel = viewModel, + onDismissDraft = { showDiscardDraftDialog = true } + ) + } +} + +@Composable +private fun ShareItem( + share: Share, + type: ShareItemType, + onSelectShare: (Share) -> Unit, + onDeleteShare: (Share) -> Unit, + onSendEmail: (Share) -> Unit +) { + var overlayState by remember { mutableStateOf(ShareItemOverlayState.None) } + val haptics = LocalHapticFeedback.current + + if (overlayState == ShareItemOverlayState.DeleteConfirmation) { + DeleteShareConfirmationDialog( + onConfirm = { + overlayState = ShareItemOverlayState.None + onDeleteShare(share) + }, + onDismiss = { overlayState = ShareItemOverlayState.None } + ) + } + + ListItem( + modifier = Modifier + .fillMaxWidth(0.9f) + .clip(type.getShape()) + .combinedClickable( + onClick = { onSelectShare(share) }, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + overlayState = ShareItemOverlayState.ContextMenu + }, + ) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + headlineContent = { + val headline = if (share.recipients.isNotEmpty()) { + share.recipients.first().displayName + } else { + "" + } + + Text( + text = headline, + style = MaterialTheme.typography.titleSmall + ) + }, + supportingContent = { + Text( + text = share.shareState.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingContent = { + Box { + IconButton(onClick = { overlayState = ShareItemOverlayState.ContextMenu }) { + Icon(Icons.Default.MoreVert, contentDescription = "More options") + } + + DropdownMenu( + expanded = overlayState == ShareItemOverlayState.ContextMenu, + onDismissRequest = { overlayState = ShareItemOverlayState.None } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.share_view_list_item_edit)) }, + onClick = { + overlayState = ShareItemOverlayState.None + onSelectShare(share) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.share_view_list_item_send_email)) }, + onClick = { + onSendEmail(share) + overlayState = ShareItemOverlayState.None + } + ) + HorizontalDivider() + DropdownMenuItem( + text = { + Text( + stringResource(R.string.share_view_list_item_delete), + color = MaterialTheme.colorScheme.error + ) + }, + onClick = { + overlayState = ShareItemOverlayState.DeleteConfirmation + } + ) + } + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) +} + +private val json = Json { ignoreUnknownKeys = true } + +fun ComposeView.initShareScreen( + sourceId: String, + sharingJson: String, + credentials: ServerCredentials, + colorScheme: ColorScheme +) { + val sharingCapabilities = json.decodeFromString(sharingJson) + val nextcloudHttpClient = NextcloudHttpClient.create(credentials) + val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient)) + + setContent { + setSingletonImageLoaderFactory { context -> + ImageLoader.Builder(context) + .components { add(SvgDecoder.Factory()) } + .build() + } + + MaterialTheme( + colorScheme = colorScheme, + content = { + ShareScreen(sourceId, sharingCapabilities, 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..42298b3e --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -0,0 +1,276 @@ +/* + * 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.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 +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.model.ui.ShareScreenState +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 +import kotlinx.coroutines.withContext + +class ShareViewModel( + private val repository: ShareRepository +) : ViewModel() { + + private val _state = MutableStateFlow(ShareScreenState.Loading) + val state: StateFlow = _state + + private val _activeShare = MutableStateFlow(null) + val activeShare: StateFlow = _activeShare + + private val _searchQuery = MutableStateFlow("") + + private val _recipientSearchResults = MutableStateFlow>(emptyList()) + val recipientSearchResults: StateFlow> = _recipientSearchResults + + 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() + } + + // 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( + sourceClass: String? = null, + lastShareID: String? = null, + limit: Int = 50 + ) { + viewModelScope.launch(Dispatchers.IO) { + _state.update { ShareScreenState.Loading } + _errorMessageId.update { null } + + val result = repository.fetchShares(sourceClass, lastShareID, limit) + handleResult(result, R.string.share_view_fetch_error_message)?.let { fetched -> + _state.update { + if (fetched.isEmpty()) ShareScreenState.Empty + else ShareScreenState.Loaded(fetched) + } + } + } + } + + fun fetchShare(id: String, request: GetShareRequest = GetShareRequest()) { + viewModelScope.launch(Dispatchers.IO) { + _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) + } + } + } + // endregion + + // region create + suspend fun createDraftShare(): Share? = withContext(Dispatchers.IO) { + _errorMessageId.update { null } + + val result = repository.createDraftShare() + val draft = handleResult(result, R.string.share_view_create_error_message) + + if (draft != null) { + _activeShare.update { draft } + _state.update { ShareScreenState.Loaded(listOf(draft) + currentShares) } + } + + draft + } + // endregion + + // region state + fun updateState(id: String, shareState: ShareState) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.updateShareState(id, UpdateShareStateRequest(shareState)) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> + _activeShare.update { updated } + replaceInList(updated) + } + } + } + // endregion + + // region sources + fun addSource(id: String, value: String) { + viewModelScope.launch(Dispatchers.IO) { + val clazz = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType" + val result = repository.addShareSource(id, AddSourceRequest(clazz, value)) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> + _activeShare.update { updated } + replaceInList(updated) + } + } + } + + fun removeSource(id: String, clazz: String, value: String) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.removeShareSource(id, clazz, value) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> + _activeShare.update { updated } + replaceInList(updated) + } + } + } + // endregion + + // region recipients + fun addRecipient(id: String, clazz: String, value: String, instance: String? = null) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.addShareRecipient(id, AddRecipientRequest(clazz, value, instance)) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> + _activeShare.update { updated } + replaceInList(updated) + } + } + } + + fun removeRecipient(id: String, clazz: String, value: String, instance: String? = null) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.removeShareRecipient(id, clazz, value, instance) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> + _activeShare.update { updated } + replaceInList(updated) + } + } + } + // endregion + + // region properties + fun updateProperty(id: String, clazz: String, value: String?) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.updateShareProperty(id, UpdateSharePropertyRequest(clazz, value)) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> + _activeShare.update { updated } + replaceInList(updated) + } + } + } + + 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 + fun updatePermission(id: String, clazz: String, enabled: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.updateSharePermission(id, UpdateSharePermissionRequest(clazz, enabled)) + handleResult(result, R.string.share_view_update_error_message)?.let { updated -> + _activeShare.update { updated } + replaceInList(updated) + } + } + } + // endregion + + // region delete + fun deleteShare(id: String) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.deleteShare(id) + handleResult(result, R.string.share_view_delete_error_message)?.let { + val remaining = currentShares.filterNot { it.id == id } + _state.update { + if (remaining.isEmpty()) ShareScreenState.Empty + else ShareScreenState.Loaded(remaining) + } + if (_activeShare.value?.id == id) _activeShare.update { null } + } + } + } + // endregion + + // region ui helpers + fun updateErrorMessage(value: Int?) { + _errorMessageId.update { value } + } + + fun setActiveShare(value: Share?) { + _activeShare.update { value } + } + // endregion + + // region private + private fun replaceInList(updated: Share) { + val shares = currentShares.ifEmpty { return } + _state.update { ShareScreenState.Loaded(shares.map { if (it.id == updated.id) updated else it }) } + } + + private fun handleResult(result: NetworkResult, errorId: Int): T? { + return when (result) { + is NetworkResult.Success -> result.data + is NetworkResult.ServerError, + is NetworkResult.NetworkException -> { + _errorMessageId.update { errorId } + null + } + } + } + // endregion +} 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..affe1da5 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CollapsibleShareSection.kt @@ -0,0 +1,60 @@ +/* + * 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.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/RecipientSearchField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt new file mode 100644 index 00000000..9dfd0a6f --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientSearchField.kt @@ -0,0 +1,230 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.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.recipients.Recipient +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()) { + RecipientChipRow( + recipients = share.recipients, + chipScrollState = chipScrollState, + onRemove = { recipient -> + viewModel.removeRecipient( + id = share.id, + clazz = recipient.clazz, + value = recipient.value, + instance = recipient.instance + ) + } + ) + } + + RecipientSearchDropdown( + query = query, + expanded = expanded, + onQueryChange = { + query = it + expanded = true + viewModel.onSearchQueryChanged(it) + }, + onExpandedChange = { expanded = it }, + onDismiss = { expanded = false } + ) { + RecipientDropdownContent( + results = results, + onSelect = { recipient -> + viewModel.addRecipient(share.id, recipient.clazz, recipient.value) + query = "" + expanded = false + } + ) + } + } +} + +@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" + ) + } + } + ) + } + } +} + +@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() + 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/ShareSwitch.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt new file mode 100644 index 00000000..8af52f43 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.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.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/component/bottomsheet/AddOrEditShareBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt new file mode 100644 index 00000000..9897bdba --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -0,0 +1,209 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + + +package com.nextcloud.android.common.ui.share.component.bottomsheet + +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.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 +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, + onDismissDraft: () -> Unit = {} +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scrollState = rememberScrollState() + val categories = remember { ShareCategory.entries.toList() } + var selectedCategory by remember { mutableStateOf(categories.first()) } + var showAdvancedSettings by remember { mutableStateOf(false) } + var expandedCategories by remember { mutableStateOf(emptySet()) } + + ModalBottomSheet( + onDismissRequest = { + if (share.shareState == ShareState.DRAFT) { + onDismissDraft() + } else { + viewModel.commitPendingProperties(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 -> + 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/dialog/DeleteShareConfirmationDialog.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DeleteShareConfirmationDialog.kt new file mode 100644 index 00000000..cf16a865 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DeleteShareConfirmationDialog.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.component.dialog + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.nextcloud.android.common.ui.R + +@Composable +fun 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 = { + FilledTonalButton( + onClick = onConfirm, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text(stringResource(R.string.common_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_cancel)) + } + } + ) +} \ No newline at end of file diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardDraftShareDialog.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardDraftShareDialog.kt new file mode 100644 index 00000000..85031c3c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/dialog/DiscardDraftShareDialog.kt @@ -0,0 +1,38 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.component.dialog + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.nextcloud.android.common.ui.R + +@Composable +fun DiscardDraftShareDialog( + onKeep: () -> Unit, + onDiscard: () -> Unit +) { + AlertDialog( + onDismissRequest = onKeep, + title = { Text(stringResource(R.string.share_view_discard_draft_title)) }, + text = { Text(stringResource(R.string.share_view_discard_draft_message)) }, + confirmButton = { + FilledTonalButton(onClick = onKeep) { + Text(stringResource(R.string.share_view_discard_draft_keep)) + } + }, + dismissButton = { + TextButton(onClick = onDiscard) { + Text(stringResource(R.string.share_view_discard_draft_delete)) + } + } + ) +} \ No newline at end of file diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt new file mode 100644 index 00000000..974e381e --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt @@ -0,0 +1,118 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component.property + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.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 +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 +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 = checkedValue, + onCheckedChange = { isChecked -> + 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 = textValue, + onValueChange = { textValue = it }, + label = { Text(property.displayName) }, + 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 = passwordValue, + onValueChange = { passwordValue = 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) + } + } +} + +@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 new file mode 100644 index 00000000..a99fae18 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt @@ -0,0 +1,119 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component.property.datepicker + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.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.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 + +@Composable +fun ShareDatePicker(property: PropertyDate, onDateSelected: (String) -> Unit) { + val formatter = ShareDateFormatter() + var showDatePicker by remember { mutableStateOf(false) } + var dateValue by remember { mutableStateOf(property.value ?: "") } + + + 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 = null + ) + } + ) + + Box( + modifier = Modifier + .matchParentSize() + .clickable { showDatePicker = true } + ) + } + + if (showDatePicker) { + DatePickerModal(formatter, onDateSelected = { + dateValue = it ?: "" + onDateSelected(dateValue) + }, onDismiss = { + showDatePicker = false + }) + } +} + +@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) + } +} + +@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 = {} + ) + } +} 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 new file mode 100644 index 00000000..7caca750 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.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.component.property.datepicker.util + +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) + } +} \ No newline at end of file diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt new file mode 100644 index 00000000..545f925c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.capabilities + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class 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, +) 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 new file mode 100644 index 00000000..d5646004 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/owner/Owner.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.owner + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import com.nextcloud.android.common.ui.share.model.api.icon.Icon + +@Serializable +data class Owner( + @SerialName("user_id") + val userId: String, + + val instance: String? = null, + + @SerialName("display_name") + val displayName: String, + + val icon: Icon +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt 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..2aeb3d8c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt @@ -0,0 +1,143 @@ +/* + * 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 + +val Property.priority: Int + get() = when (this) { + is PropertyBoolean -> priority + is PropertyDate -> priority + is PropertyEnum -> priority + is PropertyPassword -> priority + is PropertyString -> priority + } + +val Property.clazz: String + get() = when (this) { + is PropertyBoolean -> clazz + is PropertyDate -> clazz + is PropertyEnum -> clazz + is PropertyPassword -> clazz + is PropertyString -> clazz + } + +@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() { + fun isTrue(): Boolean = (value == "true") +} + +@Serializable +@SerialName("date") +data class PropertyDate( + @SerialName("class") + val clazz: String, + + @SerialName("display_name") + val displayName: String, + + val hint: String? = null, + + val priority: Int, + + val required: Boolean, + + val value: String? = null, + + @SerialName("min_date") + val minDate: String? = null, + + @SerialName("max_date") + val maxDate: String? = null +) : 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/Recipient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt new file mode 100644 index 00000000..36d876b1 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.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.recipients + +import com.nextcloud.android.common.ui.share.model.api.icon.Icon +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Recipient( + @SerialName("class") + val clazz: String, + + val value: String, + + val instance: String? = null, + + @SerialName("display_name") + val displayName: String, + + 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..3d2032e4 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.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.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 +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, + + @SerialName("state") + val shareState: ShareState, + + val sources: List, + + val recipients: List, + + 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/api/source/Source.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/source/Source.kt new file mode 100644 index 00000000..b5dfa16f --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/source/Source.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.source + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Source( + @SerialName("class") + val clazz: String, + + val value: String, + + @SerialName("display_name") + val displayName: String +) 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/ui/ShareCategory.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt new file mode 100644 index 00000000..491b1b57 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.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 ShareCategory { + Invited, Anyone +} 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() +} 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..f3c08a5f --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemType.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.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) + } + } + + companion object { + fun type(index: Int, lastIndex: Int): ShareItemType { + return when (index) { + 0 -> Top + lastIndex -> Bottom + else -> Mid + } + } + } +} 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/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..f6a63048 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -0,0 +1,432 @@ +/* + * 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.NetworkResult +import com.nextcloud.android.common.ui.share.model.api.icon.Icon +import com.nextcloud.android.common.ui.share.model.api.owner.Owner +import com.nextcloud.android.common.ui.share.model.api.permission.Permission +import com.nextcloud.android.common.ui.share.model.api.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( + recipientTypeClass: String?, + query: String, + limit: Int, + offset: Int + ): NetworkResult> { + val all = 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" + ) + ), + Recipient( + clazz = "group", + value = "marketing", + displayName = "Marketing Team", + icon = Icon( + light = "https://mock/icons/group_light.png", + dark = "https://mock/icons/group_dark.png" + ) + ), + 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" + ) + ), + 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" + ) + ) + ) + + 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(filtered) + } + + override suspend fun createDraftShare(): NetworkResult { + val share = buildShare( + id = "mock-share-${System.currentTimeMillis()}", + sources = emptyList(), + recipients = emptyList(), + shareState = ShareState.DRAFT + ) + mockShares.add(share) + return NetworkResult.Success(share) + } + + override suspend fun fetchShare( + id: String, + 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( + sourceClass: String?, + lastShareID: String?, + limit: Int + ): NetworkResult> { + var result = mockShares.toList() + + if (sourceClass != null) { + result = result.filter { share -> share.sources.any { it.clazz == sourceClass } } + } + + if (lastShareID != null) { + val index = result.indexOfFirst { it.id == lastShareID } + if (index >= 0) result = result.drop(index + 1) + } + + return NetworkResult.Success(result.take(limit)) + } + + 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) + } + + 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) + } + + 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) + } + + 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) + } + + 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) + } + + 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 new file mode 100644 index 00000000..20ceac7c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -0,0 +1,189 @@ +/* + * 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.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.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( + private val client: NextcloudHttpClient, + private val json: kotlinx.serialization.json.Json = OCSSerializer.json +) : ShareRepository { + + private 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 const val RECIPIENTS_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/recipients" + } + + override suspend fun fetchRecipients( + recipientTypeClass: String?, + query: String, + limit: Int, + offset: Int + ): 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 + } + } + + override suspend fun createDraftShare(): NetworkResult = + client.executeRequest( + endpoint = SHARE_ENDPOINT, + method = HttpMethod.POST, + body = ByteArray(0).toRequestBody() + ) { 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 + } + + override suspend fun deleteShare(id: String): NetworkResult = + client.executeRequest( + 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 + } + } + + override suspend fun updateShareState( + id: String, + request: UpdateShareStateRequest + ): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/state", + method = HttpMethod.PUT, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { body -> + json.decodeFromString>(body).ocs.data + } + + 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/source?class=$clazz&value=$value", + method = HttpMethod.DELETE + ) { body -> + json.decodeFromString>(body).ocs.data + } + + 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("?class=$clazz&value=$value") + instance?.let { append("&instance=$it") } + } + return client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/recipient$queryParams", + method = HttpMethod.DELETE + ) { body -> + 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 new file mode 100644 index 00000000..d10524b8 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt @@ -0,0 +1,80 @@ +/* + * 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.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 +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( + recipientTypeClass: String?, + query: String, + limit: Int, + offset: Int + ): NetworkResult> + + suspend fun createDraftShare(): NetworkResult + + suspend fun fetchShare( + id: String, + request: GetShareRequest = GetShareRequest() + ): NetworkResult + + suspend fun deleteShare(id: String): NetworkResult + + suspend fun fetchShares( + 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/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/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..540a7009 --- /dev/null +++ b/ui/src/main/res/values/strings.xml @@ -0,0 +1,74 @@ + + + + OK + Cancel + + Create a new share + Share %s + + File drop + Can view + Can edit + Custom permissions + + View files + Edit files + Create files + Delete files + + Settings + Add people + Name, team, email or federated ID + Participants + + Not shared + No result + Note to recipients + + Anyone with the link + + Share with others + Edit file + Expiration date + Hide download and sync options + + Label + Optional name for this link + Expiration date + Password protection + Limit downloads + Hide downloads + Video verification + + Show files in grid view + Copied + Copy link + Send + Create public link + + Edit + Send email + Delete + Delete share? + This share will be permanently removed. + + Failed to fetch shares. + + Discard new share? + This share hasn\'t been saved yet. Do you want to discard it? + Keep + Discard + + Share not found, cannot delete. + Failed to delete share. + + Failed to create share. + Failed to update share. + + \ No newline at end of file