diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 7a084140..a68fd3cb 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -76,6 +76,7 @@ + @@ -97,6 +98,7 @@ + @@ -104,6 +106,11 @@ + + + + + @@ -173,6 +180,7 @@ + @@ -244,6 +252,7 @@ + @@ -280,6 +289,7 @@ + @@ -295,6 +305,7 @@ + @@ -334,6 +345,11 @@ + + + + + @@ -19360,6 +19376,11 @@ + + + + + @@ -20855,6 +20876,11 @@ + + + + + diff --git a/material-color-utilities/build.gradle b/material-color-utilities/build.gradle index 83e301dd..119c41b4 100644 --- a/material-color-utilities/build.gradle +++ b/material-color-utilities/build.gradle @@ -1,3 +1,4 @@ + /* * Nextcloud Android Common Library * diff --git a/sample/build.gradle b/sample/build.gradle index 72fd3597..f5fc90ba 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -45,6 +45,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.11.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' implementation project(path: ':ui') testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.3.0' diff --git a/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..58e6bdf4 100644 --- a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt +++ b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt @@ -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) + 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 3d1b4d99..9c6c77a3 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 = 37 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -23,6 +24,7 @@ android { buildFeatures { compose = true + buildConfig = true } compileOptions { @@ -45,12 +47,21 @@ android { } dependencies { + // region Jetpack Compose + implementation 'androidx.compose.ui:ui-tooling-preview:1.11.4' + debugImplementation 'androidx.compose.ui:ui-tooling:1.11.4' kapt "org.jetbrains.kotlin:kotlin-metadata-jvm:${kotlinVersion}" implementation(platform("androidx.compose:compose-bom:2026.06.01")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-core") + // endregion + + implementation("io.coil-kt.coil3:coil-compose:3.5.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0") + implementation("io.coil-kt.coil3:coil-svg:3.5.0") implementation("com.vanniktech:ui:0.10.0") @@ -60,6 +71,14 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' + // region okhttp + implementation(platform("com.squareup.okhttp3:okhttp-bom:5.4.0")) + implementation("com.squareup.okhttp3:okhttp") + implementation("com.squareup.okhttp3:logging-interceptor") + // endregion + + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") + 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..29b462e6 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt @@ -0,0 +1,87 @@ +/* + * 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.BuildConfig +import com.nextcloud.android.common.ui.network.auth.AuthInterceptor +import com.nextcloud.android.common.ui.network.auth.ServerCredentials +import com.nextcloud.android.common.ui.network.model.NetworkResult +import com.nextcloud.android.common.ui.network.model.Ocs +import com.nextcloud.android.common.ui.network.model.OcsResponse +import com.nextcloud.android.common.ui.network.serialization.OCSSerializer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonElement +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import java.util.concurrent.TimeUnit + +class NextcloudHttpClient private constructor( + val okHttpClient: OkHttpClient, + val credentials: ServerCredentials, + private val debugLogger: NextcloudHttpClientLogger +) { + companion object { + private const val CONNECT_TIMEOUT_SECONDS = 90L + private const val READ_TIMEOUT_SECONDS = 90L + private const val WRITE_TIMEOUT_SECONDS = 90L + private const val OCS_OK = "ok" + + fun create( + credentials: ServerCredentials + ): NextcloudHttpClient { + 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)) + .build() + + return NextcloudHttpClient(okHttpClient, credentials, NextcloudHttpClientLogger(BuildConfig.DEBUG)) + } + } + + 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) { + debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = true) + return@withContext NetworkResult.ServerError( + OCSSerializer.json.decodeFromString>(responseBody) + ) + } + + if (responseBody.isBlank()) { + debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = false) + return@withContext NetworkResult.Success(parse(responseBody)) + } + + val envelope = OCSSerializer.json.decodeFromString>(responseBody) + if (envelope.ocs.meta.status != OCS_OK) { + debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = true) + NetworkResult.ServerError(OcsResponse(Ocs(envelope.ocs.meta, envelope.ocs.meta.message))) + } else { + debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = false) + NetworkResult.Success(parse(responseBody)) + } + } catch (e: Exception) { + debugLogger.logException(endpoint, method, e) + NetworkResult.fromException(e) + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClientLogger.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClientLogger.kt new file mode 100644 index 00000000..98d66e0c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClientLogger.kt @@ -0,0 +1,92 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.network.http + +import android.util.Log +import kotlinx.serialization.json.Json +import okhttp3.RequestBody +import okio.Buffer + +internal class NextcloudHttpClientLogger(private val enabled: Boolean) { + + companion object { + private const val TAG = "NextcloudHttpClient" + + private val prettyJson = Json { + prettyPrint = true + prettyPrintIndent = " " + ignoreUnknownKeys = true + } + } + + fun logRequest(endpoint: String, method: HttpMethod, body: RequestBody?) { + if (!enabled) return + + Log.d( + TAG, + """ + |➡️ REQUEST [$method] $endpoint + |Body: + |${body.readableString().prettyPrintJsonOrRaw()} + """.trimMargin() + ) + } + + fun logResponse( + endpoint: String, + method: HttpMethod, + code: Int, + responseBody: String, + isError: Boolean + ) { + if (!enabled) return + + Log.d( + TAG, + """ + |${statusEmojiFor(code, isError)} RESPONSE [$method] $endpoint (HTTP $code) + |Body: + |${responseBody.ifBlank { "" }.prettyPrintJsonOrRaw()} + """.trimMargin() + ) + } + + fun logException(endpoint: String, method: HttpMethod, e: Exception) { + if (!enabled) return + + Log.e(TAG, "💥 EXCEPTION [$method] $endpoint -> ${e.javaClass.simpleName}: ${e.message}", e) + } + + private fun statusEmojiFor(code: Int, isError: Boolean): String = when { + isError -> "🔴" + code in 200..299 -> "🟢" + code in 300..399 -> "🟡" + else -> "🔴" + } + + private fun RequestBody?.readableString(): String { + if (this == null) return "" + return try { + val buffer = Buffer() + writeTo(buffer) + buffer.readUtf8() + } catch (e: Exception) { + "" + } + } + + private fun String.prettyPrintJsonOrRaw(): String { + if (this.isBlank() || this == "" || this == "") return this + return try { + val element = Json.parseToJsonElement(this) + prettyJson.encodeToString(kotlinx.serialization.json.JsonElement.serializer(), element) + } catch (_: Exception) { + this + } + } +} 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..ef05856b --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.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) + } +} + +inline fun NetworkResult.dataOrElse(onError: () -> Unit): T? = + when (this) { + is NetworkResult.Success -> data + is NetworkResult.ServerError, + is NetworkResult.NetworkException -> { + onError() + null + } + } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/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..f5411ae4 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -0,0 +1,379 @@ +/* + * 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.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ColorScheme +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.LocalHapticFeedback +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +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.RecipientIcon +import com.nextcloud.android.common.ui.share.component.bottomsheet.AddOrEditShareBottomSheet +import com.nextcloud.android.common.ui.share.component.bottomsheet.QuickSharePermissionBottomSheet +import com.nextcloud.android.common.ui.share.component.dialog.DeleteShareConfirmationDialog +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset +import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption +import com.nextcloud.android.common.ui.share.model.ui.ShareItemOverlayState +import com.nextcloud.android.common.ui.share.model.ui.ShareItemType +import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState +import com.nextcloud.android.common.ui.share.model.ui.filtered +import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository +import kotlinx.coroutines.launch + +@Composable +private fun ShareScreen(sourceId: String, internalLink: String, viewModel: ShareViewModel) { + val errorMessageId by viewModel.errorMessageId.collectAsState() + val screenState by viewModel.state.collectAsState() + val activeShare by viewModel.activeShare.collectAsState() + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val resources = LocalResources.current + var editorInitialPreset by remember { mutableStateOf(null) } + + LaunchedEffect(errorMessageId) { + errorMessageId?.let { + snackbarHostState.showSnackbar(resources.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.filtered(), 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 -> + editorInitialPreset = null + viewModel.setActiveShare(selected) + }, + onCustomizeShare = { selected -> + editorInitialPreset = PermissionPresetOption.CUSTOM + viewModel.setActiveShare(selected) + }, + onChangePreset = { selected, preset -> + viewModel.updatePermissionPreset(selected.id, preset, updateActiveShare = false) + }, + onDeleteShare = { viewModel.deleteShare(it.id) }, + onSendEmail = { selected -> + editorInitialPreset = null + viewModel.setActiveShare(selected) + } + ) + } + } + } + } + } + + activeShare.shareOrNull?.let { activeShareObject -> + AddOrEditShareBottomSheet( + share = activeShareObject, + internalLink = internalLink, + viewModel = viewModel, + initialPresetOption = editorInitialPreset, + onDismissDraft = { draftShare -> + viewModel.deleteShare(draftShare.id) + viewModel.setActiveShare(null) + } + ) + } +} + +@Composable +private fun ShareItem( + share: Share, + type: ShareItemType, + onSelectShare: (Share) -> Unit, + onCustomizeShare: (Share) -> Unit, + onChangePreset: (Share, PermissionPreset) -> Unit, + onDeleteShare: (Share) -> Unit, + onSendEmail: (Share) -> Unit +) { + var overlayState by remember { mutableStateOf(ShareItemOverlayState.None) } + val haptics = LocalHapticFeedback.current + + when (overlayState) { + is ShareItemOverlayState.QuickShare -> { + QuickSharePermissionBottomSheet( + selectedOption = PermissionPresetOption.from(share.permissionPreset), + onOptionSelected = { option -> + overlayState = ShareItemOverlayState.None + val preset = option.preset + if (preset != null) { + onChangePreset(share, preset) + } else { + onCustomizeShare(share) + } + }, + onDismiss = { overlayState = ShareItemOverlayState.None } + ) + } + + is ShareItemOverlayState.DeleteConfirmation -> { + DeleteShareConfirmationDialog( + onConfirm = { + overlayState = ShareItemOverlayState.None + onDeleteShare(share) + }, + onDismiss = { overlayState = ShareItemOverlayState.None } + ) + } + + else -> Unit + } + + 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.titleMedium + ) + }, + leadingContent = { + share.recipients.first().icon?.let { RecipientIcon(icon = it) } + }, + supportingContent = { + val chipHorizontalPadding = 10.dp + Row( + modifier = Modifier + .offset(x = -chipHorizontalPadding) + .clip(RoundedCornerShape(percent = 50)) + .clickable { overlayState = ShareItemOverlayState.QuickShare } + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)) + .padding(horizontal = chipHorizontalPadding, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(PermissionPresetOption.from(share.permissionPreset).labelRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.width(2.dp)) + + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + }, + trailingContent = { + Box { + IconButton(onClick = { overlayState = ShareItemOverlayState.ContextMenu }) { + Icon(Icons.Default.MoreVert, contentDescription = "More options") + } + + DropdownMenu( + expanded = overlayState == ShareItemOverlayState.ContextMenu, + onDismissRequest = { overlayState = ShareItemOverlayState.None } + ) { + DropdownMenuItem( + leadingIcon = { + Icon(Icons.Default.Edit, + contentDescription = "Edit icon", + modifier = Modifier.size(18.dp) + ) + }, + text = { Text(stringResource(R.string.share_view_list_item_edit)) }, + onClick = { + overlayState = ShareItemOverlayState.None + onSelectShare(share) + } + ) + DropdownMenuItem( + leadingIcon = { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "Send icon", + modifier = Modifier.size(18.dp) + ) + }, + text = { Text(stringResource(R.string.share_view_list_item_send_email)) }, + onClick = { + onSendEmail(share) + overlayState = ShareItemOverlayState.None + } + ) + HorizontalDivider() + DropdownMenuItem( + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = "Delete icon", + modifier = Modifier.size(18.dp) + ) + }, + text = { + Text( + stringResource(R.string.share_view_list_item_delete), + color = MaterialTheme.colorScheme.error + ) + }, + onClick = { + overlayState = ShareItemOverlayState.DeleteConfirmation + } + ) + } + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) +} + +fun ComposeView.initShareScreen( + sourceId: String, + internalLink: String, + credentials: ServerCredentials, + colorScheme: ColorScheme +) { + 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, internalLink, viewModel) + } + ) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt new file mode 100644 index 00000000..a32d7bc9 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt @@ -0,0 +1,363 @@ +/* + * 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.network.model.dataOrElse +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient +import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest +import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest +import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionPresetRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipientSecretRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest +import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.ui.ActiveShareState +import com.nextcloud.android.common.ui.share.model.ui.ShareCategory +import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState +import com.nextcloud.android.common.ui.share.model.ui.filtered +import com.nextcloud.android.common.ui.share.repository.ShareRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds + +class ShareViewModel( + private val repository: ShareRepository +) : ViewModel() { + + companion object { + private const val SEARCH_DEBOUNCE_DELAY = 300L + private const val PROPERTY_DEBOUNCE_DELAY = 1000L + } + + private val _state = MutableStateFlow(ShareScreenState.Loading) + val state: StateFlow = _state + + private val _activeShare = MutableStateFlow(ActiveShareState.None) + val activeShare: StateFlow = _activeShare + + private val _searchQuery = MutableStateFlow("") + + private val _recipientSearchResults = MutableStateFlow>(emptyList()) + val recipientSearchResults: StateFlow> = _recipientSearchResults + + private val _errorMessageId = MutableStateFlow(null) + val errorMessageId: StateFlow = _errorMessageId + + private val _propertyErrors = MutableStateFlow>(emptyMap()) + val propertyErrors: StateFlow> = _propertyErrors + + private val propertyUpdateJobs = mutableMapOf() + + private var secretUpdateJob: Job? = null + + 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(SEARCH_DEBOUNCE_DELAY.milliseconds) + .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) + val fetched = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } + ?: return@launch + _state.update { + if (fetched.filtered().isEmpty()) ShareScreenState.Empty + else ShareScreenState.Loaded(fetched) + } + } + } + + fun fetchShare(id: String, request: GetShareRequest = GetShareRequest()) { + viewModelScope.launch(Dispatchers.IO) { + _errorMessageId.update { null } + + val result = repository.fetchShare(id, request) + val share = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } + ?: return@launch + + _activeShare.update { share.toActiveShare() } + replaceInList(share) + } + } + // endregion + + // region create + suspend fun createDraftShare(): Share? = withContext(Dispatchers.IO) { + _errorMessageId.update { null } + + val result = repository.createDraftShare() + + val draft = result.dataOrElse { _errorMessageId.update { R.string.share_view_create_error_message } } + + if (draft != null) { + _activeShare.update { draft.toActiveShare() } + _state.update { ShareScreenState.Loaded(listOf(draft) + currentShares) } + } + + draft + } + // endregion + + // region state + fun updateState(id: String, shareState: ShareState, updateAndDontDismiss: Boolean = false) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.updateShareState(id, UpdateShareStateRequest(shareState)) + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + if (shareState == ShareState.ACTIVE) { + if (updateAndDontDismiss) { + _activeShare.update { ActiveShareState.Activating(updated) } + } else { + _activeShare.update { ActiveShareState.None } + } + } else { + _activeShare.update { updated.toActiveShare() } + } + replaceInList(updated) + } + } + // endregion + + // region sources + fun addSource(id: String, value: String) { + viewModelScope.launch(Dispatchers.IO) { + // TODO pass from the clients this may vary depends on the client so notes and files for now uses this + val clazz = "OCA\\Files\\Sharing\\Source\\NodeShareSourceType" + val result = repository.addShareSource(id, AddSourceRequest(clazz, value)) + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + _activeShare.update { updated.toActiveShare() } + replaceInList(updated) + } + } + + fun removeSource(id: String, clazz: String, value: String) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.removeShareSource(id, clazz, value) + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + _activeShare.update { updated.toActiveShare() } + 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)) + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + _activeShare.update { updated.toActiveShare() } + replaceInList(updated) + } + } + + fun selectCategory(category: ShareCategory, share: Share) { + when (category) { + ShareCategory.Anyone -> { + removeRecipients(share) { it.clazz != Recipient.TOKEN_RECIPIENT_CLASS } + if (share.recipients.none { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS }) { + addRecipient( + id = share.id, + clazz = Recipient.TOKEN_RECIPIENT_CLASS, + value = UUID.randomUUID().toString(), + instance = null + ) + } + } + + ShareCategory.Invited -> { + removeRecipients(share) { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS } + } + } + } + + private fun removeRecipients(share: Share, predicate: (Recipient) -> Boolean) { + share.recipients + .filter(predicate) + .forEach { removeRecipient(share.id, it.clazz, it.value, it.instance) } + } + + fun removeRecipient(id: String, clazz: String, value: String, instance: String? = null) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.removeShareRecipient(id, clazz, value, instance) + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + _activeShare.update { updated.toActiveShare() } + replaceInList(updated) + } + } + + fun updateRecipientSecret(shareId: String, recipient: Recipient, secret: String) { + secretUpdateJob?.cancel() + secretUpdateJob = viewModelScope.launch(Dispatchers.IO) { + delay(PROPERTY_DEBOUNCE_DELAY.milliseconds) + val request = UpdateShareRecipientSecretRequest( + clazz = recipient.clazz, + value = recipient.value, + instance = recipient.instance, + secret = secret + ) + val result = repository.updateShareRecipientSecret(shareId, request) + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + _activeShare.update { updated.toActiveShare() } + replaceInList(updated) + } + } + + suspend fun generateSecret(): String? = withContext(Dispatchers.IO) { + _errorMessageId.update { null } + val result = repository.generateSecret() + result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + } + // endregion + + // region properties + fun updateProperty(shareId: String, clazz: String, value: String?) { + propertyUpdateJobs[clazz]?.cancel() + propertyUpdateJobs[clazz] = viewModelScope.launch(Dispatchers.IO) { + delay(PROPERTY_DEBOUNCE_DELAY.milliseconds) + when (val result = repository.updateShareProperty(shareId, UpdateSharePropertyRequest(clazz, value))) { + is NetworkResult.Success -> { + _propertyErrors.update { it - clazz } + _activeShare.update { result.data.toActiveShare() } + replaceInList(result.data) + } + + is NetworkResult.ServerError -> { + _propertyErrors.update { it + (clazz to result.response.ocs.meta.message) } + } + + is NetworkResult.NetworkException -> { + _propertyErrors.update { it + (clazz to null) } + } + } + } + } + // endregion + + // region permissions + fun updatePermission(id: String, clazz: String, enabled: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.updateSharePermission(id, UpdateSharePermissionRequest(clazz, enabled)) + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + replaceInList(updated) + } + } + + fun updatePermissionPreset(id: String, preset: PermissionPreset, updateActiveShare: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.updateSharePermissionPreset(id, UpdateSharePermissionPresetRequest(preset)) + val updated = result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + if (updateActiveShare) { + _activeShare.update { updated.toActiveShare() } + } + replaceInList(updated) + } + } + // endregion + + // region delete + fun deleteShare(id: String) { + viewModelScope.launch(Dispatchers.IO) { + val result = repository.deleteShare(id) + result.dataOrElse { _errorMessageId.update { R.string.share_view_delete_error_message } } ?: return@launch + + val remaining = currentShares.filterNot { it.id == id } + _state.update { + if (remaining.filtered().isEmpty()) ShareScreenState.Empty + else ShareScreenState.Loaded(remaining) + } + + val editingShare = _activeShare.value + if (editingShare is ActiveShareState.Editing && editingShare.share.id == id) { + _activeShare.update { ActiveShareState.None } + } + } + } + // endregion + + // region ui helpers + fun updateErrorMessage(value: Int?) { + _errorMessageId.update { value } + } + + fun setActiveShare(value: Share?) { + _propertyErrors.update { emptyMap() } + + value?.let { + _activeShare.update { value.toActiveShare() } + } + } + // 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 }) } + } + // 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..91e640c4 --- /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().padding(16.dp)) { + 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/CustomLink.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt new file mode 100644 index 00000000..8c581c52 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt @@ -0,0 +1,97 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient +import kotlinx.coroutines.launch + +private const val MAX_TOKEN_LENGTH = 32 + +@Composable +fun CustomLink( + recipient: Recipient, + onGenerateSecret: suspend () -> String?, + onTokenChange: (String) -> Unit +) { + val scope = rememberCoroutineScope() + val prefix = remember(recipient.secret.url, recipient.secret.value) { + val url = recipient.secret.url.orEmpty() + val token = recipient.secret.value.orEmpty() + if (token.isNotEmpty() && url.endsWith(token)) url.removeSuffix(token) else url + } + var token by remember(recipient.value) { mutableStateOf(recipient.secret.value ?: "") } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = stringResource(R.string.share_view_custom_link_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = stringResource(R.string.share_view_custom_link_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp) + ) + + OutlinedTextField( + value = token, + onValueChange = { newValue -> + val trimmed = newValue.take(MAX_TOKEN_LENGTH) + token = trimmed + if (trimmed.isNotBlank()) onTokenChange(trimmed) + }, + label = { Text(prefix) }, + singleLine = true, + trailingIcon = { + IconButton( + onClick = { + scope.launch { + val generated = onGenerateSecret() ?: return@launch + token = generated + onTokenChange(generated) + } + } + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(R.string.share_view_custom_link_refresh) + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt new file mode 100644 index 00000000..82bf6618 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt @@ -0,0 +1,190 @@ +/* + * 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.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.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.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 + +@Composable +fun SelectRecipientField( + share: Share, + viewModel: ShareViewModel +) { + val selectedRecipient = share.recipients.firstOrNull { it.clazz != Recipient.TOKEN_RECIPIENT_CLASS } + + if (selectedRecipient == null) { + RecipientSearch(share, viewModel) + return + } + + SelectedRecipient( + recipient = selectedRecipient, + onClear = { + viewModel.removeRecipient( + id = share.id, + clazz = selectedRecipient.clazz, + value = selectedRecipient.value, + instance = selectedRecipient.instance + ) + } + ) +} + +@Composable +private fun SelectedRecipient(recipient: Recipient, onClear: () -> Unit) { + OutlinedTextField( + value = recipient.displayName, + onValueChange = {}, + readOnly = true, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + label = { Text(stringResource(R.string.share_view_select_recipient_label)) }, + leadingIcon = { + recipient.icon?.let { RecipientIcon(icon = it, modifier = Modifier.size(24.dp)) } + }, + trailingIcon = { + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.share_view_select_recipient_clear) + ) + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RecipientSearch(share: Share, viewModel: ShareViewModel) { + var query by remember { mutableStateOf("") } + var expanded by remember { mutableStateOf(false) } + val results by viewModel.recipientSearchResults.collectAsState() + + ExposedDropdownMenuBox( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + expanded = expanded && query.isNotBlank(), + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = query, + onValueChange = { + query = it + expanded = true + viewModel.onSearchQueryChanged(it) + }, + label = { Text(stringResource(R.string.share_view_select_recipient_label)) }, + singleLine = true, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true) + .fillMaxWidth() + ) + + if (query.isNotBlank()) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + RecipientDropdownContent( + results = results, + onSelect = { recipient -> + viewModel.addRecipient(share.id, recipient.clazz, recipient.value) + query = "" + expanded = false + } + ) + } + } + } +} + +@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 +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..ebe0744d --- /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, horizontal = 16.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..e86484a5 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -0,0 +1,445 @@ +/* + * 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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +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.CustomLink +import com.nextcloud.android.common.ui.share.component.SelectRecipientField +import com.nextcloud.android.common.ui.share.component.ShareSwitch +import com.nextcloud.android.common.ui.share.component.property.SharePropertyView +import com.nextcloud.android.common.ui.share.model.api.permission.Permission +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset +import com.nextcloud.android.common.ui.share.model.api.property.PropertyString +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient +import com.nextcloud.android.common.ui.share.model.api.secret.Secret +import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.api.source.Source +import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.api.user.User +import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption +import com.nextcloud.android.common.ui.share.model.ui.ShareCategory +import com.nextcloud.android.common.ui.share.repository.MockShareRepository +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddOrEditShareBottomSheet( + share: Share, + internalLink: String, + viewModel: ShareViewModel, + initialPresetOption: PermissionPresetOption? = null, + onDismissDraft: (Share) -> Unit = {} +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val categories = remember { ShareCategory.entries.toList() } + var selectedCategory by remember { mutableStateOf(categories.first()) } + var showAdvancedSettings by remember { mutableStateOf(false) } + val context = LocalContext.current + val propertyErrors by viewModel.propertyErrors.collectAsState() + val hasPropertyErrors = propertyErrors.values.any { it != null } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + ModalBottomSheet( + onDismissRequest = { + if (share.shareState == ShareState.DRAFT) { + onDismissDraft(share) + } else { + viewModel.setActiveShare(null) + } + }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Text( + text = share.title(context), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(16.dp) + ) + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + categories.forEachIndexed { index, category -> + SegmentedButton( + selected = selectedCategory == category, + onClick = { + selectedCategory = category + viewModel.selectCategory(category, share) + }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size), + icon = { + Icon(painter = painterResource(category.iconId), contentDescription = "") + } + ) { + Text(stringResource(category.titleId)) + } + } + } + + if (selectedCategory == ShareCategory.Invited) { + SelectRecipientField(share, viewModel) + } + + PermissionsView( + share = share, + initialPresetOption = initialPresetOption, + viewModel = viewModel + ) + + if (share.properties.isNotEmpty() || share.customLinkRecipient != null) { + AdvancedSettingsSection( + share = share, + isExpanded = showAdvancedSettings, + onToggle = { showAdvancedSettings = !showAdvancedSettings }, + viewModel = viewModel + ) + } + + if (hasPropertyErrors) { + Text( + text = stringResource(R.string.share_view_property_error_warning), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + + if (share.canSend) { + ActionButtons( + share = share, + internalLink = internalLink, + category = selectedCategory, + sendEnabled = !hasPropertyErrors, + viewModel = viewModel + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PermissionsView( + share: Share, + initialPresetOption: PermissionPresetOption?, + viewModel: ShareViewModel +) { + var selectedPreset by remember(share.id) { + mutableStateOf(if (initialPresetOption != null) initialPresetOption.preset else share.permissionPreset) + } + + PermissionPresetDropdown( + selectedOption = PermissionPresetOption.from(selectedPreset), + onOptionSelected = { option -> + selectedPreset = option.preset + option.preset?.let { viewModel.updatePermissionPreset(share.id, it, true) } + } + ) + + if (selectedPreset != null) return + + share.permissions.forEach { permission -> + key(permission.clazz) { + var checked by remember { mutableStateOf(permission.enabled) } + ShareSwitch( + label = permission.displayName, + checked = checked, + onCheckedChange = { isChecked -> + checked = isChecked + viewModel.updatePermission(share.id, permission.clazz, isChecked) + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PermissionPresetDropdown( + selectedOption: PermissionPresetOption, + onOptionSelected: (PermissionPresetOption) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + modifier = Modifier.padding(12.dp), + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = stringResource(selectedOption.labelRes), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.share_view_permission_preset_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, true) + .fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + PermissionPresetOption.entries.forEach { option -> + DropdownMenuItem( + text = { Text(stringResource(option.labelRes)) }, + onClick = { + expanded = false + onOptionSelected(option) + } + ) + } + } + } +} + +@Composable +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 + ) + } + } + + share.customLinkRecipient?.let { + CustomLink( + recipient = it, + onGenerateSecret = { viewModel.generateSecret() }, + onTokenChange = { token -> + viewModel.updateRecipientSecret(share.id, it, token) + } + ) + } + } +} + +@Composable +private fun ActionButtons( + share: Share, + internalLink: String, + category: ShareCategory, + sendEnabled: Boolean, + viewModel: ShareViewModel +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val localClipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + Button( + onClick = { + scope.launch { + share.getClipEntry(internalLink, category)?.let { + if (category == ShareCategory.Anyone) { + + // recompose ActionButtons + viewModel.updateState(share.id, ShareState.ACTIVE, updateAndDontDismiss = true) + + // share object is updated active share + if (share.shareState == ShareState.ACTIVE) { + localClipboard.setClipEntry(it) + } + } else { + localClipboard.setClipEntry(it) + } + } + } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_link), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(category.copyLinkTitleId), + modifier = Modifier.padding(start = 8.dp), + ) + } + + if (category == ShareCategory.Invited) { + Button( + onClick = { + viewModel.updateState(share.id, ShareState.ACTIVE) + }, + enabled = sendEnabled, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Icon( + imageVector = category.sendActionIcon, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(category.sendActionTitleId), + modifier = Modifier.padding(start = 8.dp), + ) + } + } else { + Button( + onClick = { + viewModel.updateState(share.id, ShareState.ACTIVE) + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Text( + text = stringResource(category.sendActionTitleId), + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun AddOrEditShareBottomSheetPreview() { + MaterialTheme { + AddOrEditShareBottomSheet( + share = previewShare, + internalLink = "internal_link", + viewModel = ShareViewModel(MockShareRepository()) + ) + } +} + +private val previewIcon = + com.nextcloud.android.common.ui.share.model.api.icon.Icon(svg = "") + +private val previewUser = User( + userId = "alice", + displayName = "Alice Johnson", + icon = previewIcon +) + +private val previewShare = Share( + id = "preview", + owner = previewUser, + lastUpdated = 0L, + shareState = ShareState.DRAFT, + sources = listOf( + Source(clazz = "file", value = "/Photos/vacation.jpg", displayName = "vacation.jpg", icon = previewIcon) + ), + recipients = listOf( + Recipient( + clazz = "user", + value = "bob@example.com", + displayName = "Bob Smith", + icon = previewIcon, + secret = Secret(updatable = false, value = "", url = "https://example.com/s/abc123") + ) + ), + properties = listOf( + PropertyString(clazz = "note", displayName = "Note", priority = 10, required = false, value = "") + ), + permissions = listOf( + Permission( + clazz = "read", + displayName = "Read", + presets = listOf(PermissionPreset.VIEW, PermissionPreset.EDIT), + enabled = true + ), + Permission( + clazz = "update", + displayName = "Update", + presets = listOf(PermissionPreset.EDIT), + enabled = false + ) + ), + permissionPreset = null +) + diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/QuickSharePermissionBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/QuickSharePermissionBottomSheet.kt new file mode 100644 index 00000000..0fdbbe75 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/QuickSharePermissionBottomSheet.kt @@ -0,0 +1,76 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.android.common.ui.share.component.bottomsheet + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuickSharePermissionBottomSheet( + selectedOption: PermissionPresetOption, + onOptionSelected: (PermissionPresetOption) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp) + ) { + Text( + text = stringResource(R.string.share_view_quick_permission_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(16.dp) + ) + + PermissionPresetOption.entries.forEach { option -> + ListItem( + modifier = Modifier.clickable { onOptionSelected(option) }, + headlineContent = { Text(stringResource(option.labelRes)) }, + trailingContent = { + if (option == selectedOption) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + } + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/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/property/SharePropertyEnumField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyEnumField.kt new file mode 100644 index 00000000..301bd373 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyEnumField.kt @@ -0,0 +1,75 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component.property + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.share.model.api.property.PropertyEnum + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SharePropertyEnumField( + property: PropertyEnum, + errorMessage: String?, + onValueSelected: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + var selectedValue by remember(property.clazz) { mutableStateOf(property.value ?: "") } + + ExposedDropdownMenuBox( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = selectedValue, + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { Text(property.displayName) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + isError = errorMessage != null, + supportingText = errorMessage?.let { { Text(it) } }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, true) + .fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + property.validValues.forEach { value -> + DropdownMenuItem( + text = { Text(value) }, + onClick = { + selectedValue = value + expanded = false + onValueSelected(value) + } + ) + } + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt new file mode 100644 index 00000000..a5318ddb --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt @@ -0,0 +1,123 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component.property + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.ShareViewModel +import com.nextcloud.android.common.ui.share.component.ShareSwitch +import com.nextcloud.android.common.ui.share.component.property.datepicker.ShareDatePicker +import com.nextcloud.android.common.ui.share.model.api.property.Property +import com.nextcloud.android.common.ui.share.model.api.property.PropertyBoolean +import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate +import com.nextcloud.android.common.ui.share.model.api.property.PropertyEnum +import com.nextcloud.android.common.ui.share.model.api.property.PropertyPassword +import com.nextcloud.android.common.ui.share.model.api.property.PropertyString + +@Composable +fun SharePropertyView(shareId: String, property: Property, viewModel: ShareViewModel) { + val propertyErrors by viewModel.propertyErrors.collectAsState() + val fallbackError = stringResource(R.string.share_view_update_error_message) + val errorMessage = if (propertyErrors.containsKey(property.clazz)) { + propertyErrors[property.clazz] ?: fallbackError + } else { + null + } + + when (property) { + is PropertyBoolean -> { + var checkedValue by remember(property.clazz) { mutableStateOf(property.isTrue()) } + + Column { + ShareSwitch( + label = property.displayName, + checked = checkedValue, + onCheckedChange = { isChecked -> + checkedValue = isChecked + viewModel.updateProperty(shareId, property.clazz, isChecked.toString()) + } + ) + if (errorMessage != null) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp) + ) + } + } + } + + is PropertyString -> { + var textValue by remember(property.clazz) { mutableStateOf(property.value ?: "") } + + OutlinedTextField( + value = textValue, + onValueChange = { + textValue = it + viewModel.updateProperty(shareId, property.clazz, it) + }, + label = { Text(property.displayName) }, + isError = errorMessage != null, + supportingText = errorMessage?.let { { Text(it) } }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + singleLine = true + ) + } + + is PropertyPassword -> { + var passwordValue by remember(property.clazz) { mutableStateOf(property.value ?: "") } + + OutlinedTextField( + value = passwordValue, + onValueChange = { + passwordValue = it + viewModel.updateProperty(shareId, property.clazz, it) + }, + label = { Text(property.displayName) }, + placeholder = property.hint?.let { { Text(it) } }, + visualTransformation = PasswordVisualTransformation(), + isError = errorMessage != null, + supportingText = errorMessage?.let { { Text(it) } }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + singleLine = true + ) + } + + is PropertyDate -> { + ShareDatePicker(property, errorMessage = errorMessage, onDateSelected = { dateValue -> + viewModel.updateProperty(shareId, property.clazz, dateValue) + }) + } + + is PropertyEnum -> { + SharePropertyEnumField( + property = property, + errorMessage = errorMessage, + onValueSelected = { value -> viewModel.updateProperty(shareId, property.clazz, value) } + ) + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt new file mode 100644 index 00000000..0e8c76c9 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt @@ -0,0 +1,126 @@ +/* + * 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, errorMessage: String?, onDateSelected: (String) -> Unit) { + val formatter = remember { ShareDateFormatter() } + var showDatePicker by remember { mutableStateOf(false) } + var dateValue by remember(property.clazz) { mutableStateOf(formatter.formatIsoForDisplay(property.value)) } + + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = dateValue, + onValueChange = {}, + readOnly = true, + enabled = true, + isError = errorMessage != null, + supportingText = errorMessage?.let { { Text(it) } }, + 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 = formatter, + onDateSelected = { displayDate, isoDate -> + dateValue = displayDate ?: "" + onDateSelected(isoDate ?: "") + }, + onDismiss = { showDatePicker = false } + ) + } +} + +@Composable +private fun DatePickerModal( + formatter: ShareDateFormatter, + onDateSelected: (displayDate: String?, isoDate: String?) -> Unit, + onDismiss: () -> Unit +) { + val datePickerState = rememberDatePickerState() + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + onDateSelected( + formatter.formatDisplayDate(datePickerState), + formatter.formatIso8601Date(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" + ), + errorMessage = null, + onDateSelected = {} + ) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt new file mode 100644 index 00000000..8e766bfb --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatter.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.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.LocalDate +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +class ShareDateFormatter { + + private val displayDateFormatter: DateTimeFormatter = + DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(Locale.getDefault()) + + fun getDisplayName(property: PropertyDate): String = property.displayName + + @OptIn(ExperimentalMaterial3Api::class) + fun formatDisplayDate(datePickerState: DatePickerState): String? = + datePickerState.getSelectedDate()?.format(displayDateFormatter) + + @OptIn(ExperimentalMaterial3Api::class) + fun formatIso8601Date(datePickerState: DatePickerState): String? = + datePickerState.getSelectedDate() + ?.atStartOfDay(ZoneId.systemDefault()) + ?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + + fun formatIsoForDisplay(isoDate: String?): String { + if (isoDate.isNullOrBlank()) return "" + return runCatching { OffsetDateTime.parse(isoDate).toLocalDate().format(displayDateFormatter) } + .recoverCatching { LocalDate.parse(isoDate).format(displayDateFormatter) } + .getOrDefault(isoDate) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/Legacy.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/Legacy.kt new file mode 100644 index 00000000..cef0c5b9 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/Legacy.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.capabilities + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Legacy( + @SerialName("max_sources") + val maxSources: Long, + @SerialName("max_recipients") + val maxRecipients: Long, +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt new file mode 100644 index 00000000..04eb864d --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.capabilities + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SharingCapabilities( + @SerialName("api_versions") + val apiVersions: List, + val legacy: Legacy? = null, + @SerialName("source_types") + val sourceTypes: List, +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SourceType.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SourceType.kt new file mode 100644 index 00000000..dbdfe0a6 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SourceType.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.capabilities + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SourceType( + @SerialName("class") + val classField: String, +) 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/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..d568ae08 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/Permission.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.permission + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Permission( + @SerialName("class") + val clazz: String, + + @SerialName("source_class") + val sourceClass: String? = null, + + @SerialName("display_name") + val displayName: String, + + val hint: String? = null, + + val presets: List, + + val enabled: Boolean +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/PermissionPreset.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/PermissionPreset.kt new file mode 100644 index 00000000..fb4f321c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/PermissionPreset.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.permission + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class PermissionPreset { + @SerialName("view") + VIEW, + + @SerialName("edit") + EDIT +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/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..27522128 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt @@ -0,0 +1,87 @@ +/* + * 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 { + abstract val clazz: String + abstract val displayName: String + abstract val hint: String? + abstract val priority: Int + abstract val required: Boolean + abstract val value: String? +} + +@Serializable +@SerialName("boolean") +data class PropertyBoolean( + @SerialName("class") override val clazz: String, + @SerialName("display_name") override val displayName: String, + override val hint: String? = null, + override val priority: Int, + override val required: Boolean, + override val value: String? = null +) : Property() { + fun isTrue(): Boolean = value == "true" +} + +@Serializable +@SerialName("date") +data class PropertyDate( + @SerialName("class") override val clazz: String, + @SerialName("display_name") override val displayName: String, + override val hint: String? = null, + override val priority: Int, + override val required: Boolean, + override val value: String? = null, + @SerialName("min_date") val minDate: String? = null, + @SerialName("max_date") val maxDate: String? = null +) : Property() + +@Serializable +@SerialName("enum") +data class PropertyEnum( + @SerialName("class") override val clazz: String, + @SerialName("display_name") override val displayName: String, + override val hint: String? = null, + override val priority: Int, + override val required: Boolean, + override val value: String? = null, + @SerialName("valid_values") val validValues: List +) : Property() + +@Serializable +@SerialName("password") +data class PropertyPassword( + @SerialName("class") override val clazz: String, + @SerialName("display_name") override val displayName: String, + override val hint: String? = null, + override val priority: Int, + override val required: Boolean, + override val value: String? = null +) : Property() + +@Serializable +@SerialName("string") +data class PropertyString( + @SerialName("class") override val clazz: String, + @SerialName("display_name") override val displayName: String, + override val hint: String? = null, + override val priority: Int, + override val required: Boolean, + override val value: String? = null, + @SerialName("min_length") val minLength: Int? = null, + @SerialName("max_length") val maxLength: Int? = null +) : Property() diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/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..d5f967d5 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.recipients + +import com.nextcloud.android.common.ui.share.model.api.icon.Icon +import com.nextcloud.android.common.ui.share.model.api.secret.Secret +import com.nextcloud.android.common.ui.share.model.api.user.User +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@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, + + val secret: Secret, + + val initiator: User? = null +) { + companion object { + const val TOKEN_RECIPIENT_CLASS = """OC\Core\Sharing\Recipient\TokenShareRecipientType""" + } +} 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..c39733b3 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt @@ -0,0 +1,77 @@ +/* + * 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.permission.PermissionPreset +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( + @SerialName("state") + 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 +) + +@Serializable +data class UpdateSharePermissionPresetRequest( + val permissionPreset: PermissionPreset +) + +@Serializable +data class UpdateShareRecipientSecretRequest( + @SerialName("class") + val clazz: String, + + val value: String, + + val instance: String? = null, + + val secret: String +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/secret/Secret.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/secret/Secret.kt new file mode 100644 index 00000000..b951e6d0 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/secret/Secret.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.secret + +import kotlinx.serialization.Serializable + +@Serializable +data class Secret( + val updatable: Boolean, + val value: String? = null, + val url: String? = null +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt new file mode 100644 index 00000000..c9388669 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -0,0 +1,85 @@ +/* + * 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.ClipData +import android.content.Context +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.toClipEntry +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.api.permission.Permission +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset +import com.nextcloud.android.common.ui.share.model.api.property.Property +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient +import com.nextcloud.android.common.ui.share.model.api.source.Source +import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.api.user.User +import com.nextcloud.android.common.ui.share.model.ui.ActiveShareState +import com.nextcloud.android.common.ui.share.model.ui.ShareCategory +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Share( + val id: String, + + val owner: User, + + @SerialName("last_updated") + val lastUpdated: Long, + + @SerialName("state") + val shareState: ShareState, + + val sources: List, + + val recipients: List, + + val properties: List, + + val permissions: List, + + @SerialName("permission_preset") + val permissionPreset: PermissionPreset? = null +) { + fun toActiveShare(): ActiveShareState { + return ActiveShareState.Editing(this) + } + + fun getClipEntry(internalLink: String, category: ShareCategory): ClipEntry? { + val recipient = recipients.firstOrNull() ?: return null + + return if (category == ShareCategory.Anyone) { + val link = recipient.secret.url ?: return null + ClipData.newPlainText(recipient.displayName, link).toClipEntry() + } else { + ClipData.newPlainText(recipient.displayName, internalLink).toClipEntry() + } + } + + val customLinkRecipient: Recipient? + get() { + return recipients.firstOrNull { it.secret.updatable } + } + + val canSend: Boolean + get() { + return sources.isNotEmpty() && + recipients.isNotEmpty() && + permissions.any { it.enabled } && + properties.none { it.required && it.value.isNullOrEmpty() } + } + + fun title(context: Context): String { + return if (shareState == ShareState.DRAFT) { + context.getString(R.string.share_view_bottom_sheet_new_title) + } else { + context.getString(R.string.share_view_bottom_sheet_edit_title, sources.firstOrNull()?.displayName) + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/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..0635a4bd --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/source/Source.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.source + +import com.nextcloud.android.common.ui.share.model.api.icon.Icon +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, + + val icon: Icon? = null +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/state/ShareState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/state/ShareState.kt new file mode 100644 index 00000000..2846dc06 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/state/ShareState.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.state + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class ShareState { + @SerialName("active") + ACTIVE, + + @SerialName("draft") + DRAFT, + + @SerialName("deleted") + DELETED +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/User.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/User.kt new file mode 100644 index 00000000..d0870bf3 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/User.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.user + +import com.nextcloud.android.common.ui.share.model.api.icon.Icon +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class User( + @SerialName("user_id") + val userId: String, + + val instance: String? = null, + + @SerialName("display_name") + val displayName: String, + + val icon: Icon +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ActiveShareState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ActiveShareState.kt new file mode 100644 index 00000000..e7b56192 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ActiveShareState.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.ui + +import com.nextcloud.android.common.ui.share.model.api.share.Share + +sealed class ActiveShareState { + data object None : ActiveShareState() + data class Editing(val share: Share) : ActiveShareState() + data class Activating(val share: Share) : ActiveShareState() + + val shareOrNull: Share? + get() = when (this) { + is Editing -> share + is Activating -> share + None -> null + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOption.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOption.kt new file mode 100644 index 00000000..886a0f75 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOption.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.ui + +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset + +enum class PermissionPresetOption( + val preset: PermissionPreset?, + val labelRes: Int +) { + CUSTOM(null, R.string.share_view_permission_preset_custom), + VIEW(PermissionPreset.VIEW, R.string.share_view_permission_preset_view), + EDIT(PermissionPreset.EDIT, R.string.share_view_permission_preset_edit); + + companion object { + fun from(preset: PermissionPreset?): PermissionPresetOption = + entries.firstOrNull { it.preset == preset } ?: CUSTOM + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/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..6522188e --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Share +import com.nextcloud.android.common.ui.R + +enum class ShareCategory(val titleId: Int, val iconId: Int) { + Invited( + R.string.share_view_invited_category_title, + R.drawable.ic_person_add + ), + Anyone(R.string.share_view_anyone_category_title, R.drawable.ic_anyone); + + val copyLinkTitleId + get() = if (this == Invited) R.string.share_view_invited_copy_action else R.string.share_view_anyone_copy_action + + val sendActionTitleId + get() = if (this == Invited) R.string.share_view_send_action else R.string.share_view_share_action + + val sendActionIcon + get() = if (this == Invited) Icons.AutoMirrored.Filled.Send else Icons.Default.Share +} 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..0b47eff0 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemOverlayState.kt @@ -0,0 +1,15 @@ +/* + * 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 QuickShare : 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..1b912e17 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareScreenState.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.ui + +import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.api.state.ShareState + +sealed class ShareScreenState { + data object Empty: ShareScreenState() + data object Loading: ShareScreenState() + data class Loaded(val shares: List): ShareScreenState() +} + +fun List.filtered(): List = filter { it.shareState != ShareState.DRAFT } 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..96c43283 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -0,0 +1,471 @@ +/* + * 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.permission.Permission +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset +import com.nextcloud.android.common.ui.share.model.api.property.PropertyBoolean +import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate +import com.nextcloud.android.common.ui.share.model.api.property.PropertyPassword +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.UpdateSharePermissionPresetRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipientSecretRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest +import com.nextcloud.android.common.ui.share.model.api.secret.Secret +import com.nextcloud.android.common.ui.share.model.api.share.Share +import com.nextcloud.android.common.ui.share.model.api.source.Source +import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import com.nextcloud.android.common.ui.share.model.api.user.User + +class MockShareRepository : ShareRepository { + + private val mockOwner = User( + 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", + presets = listOf(PermissionPreset.VIEW, PermissionPreset.EDIT), + enabled = true + ), + Permission( + clazz = "update", + displayName = "Update", + presets = listOf(PermissionPreset.EDIT), + enabled = false + ), + Permission( + clazz = "delete", + displayName = "Delete", + presets = listOf(PermissionPreset.EDIT), + 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: User = 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" + ), + secret = Secret(false, value = "", url = "") + ) + ) + ), + 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" + ), + secret = Secret(false, value = "", url = "") + ) + ), + owner = User( + 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 = User( + 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" + ), + secret = Secret(false, value = "", url = "") + ) + ), + owner = User( + 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" + ), + secret = Secret(false, value = "", url = "") + ) + ), + owner = User( + 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( + recipientTypeClasses: List?, + 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" + ), + secret = Secret(false, value = "", url = "") + ), + Recipient( + clazz = "group", + value = "marketing", + displayName = "Marketing Team", + icon = Icon( + light = "https://mock/icons/group_light.png", + dark = "https://mock/icons/group_dark.png" + ), + secret = Secret(false, value = "", url = "") + ), + 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" + ), + secret = Secret(false, value = "", url = "") + ), + 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" + ), + secret = Secret(false, value = "", url = "") + ) + ) + + val filtered = all + .filter { recipientTypeClasses.isNullOrEmpty() || it.clazz in recipientTypeClasses } + .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, + secret = Secret(false, value = "", url = "") + ) + 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 updateShareRecipientSecret( + id: String, + request: UpdateShareRecipientSecretRequest + ): NetworkResult { + val index = mockShares.indexOfFirst { it.id == id } + val current = if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList()) + return NetworkResult.Success(current) + } + + override suspend fun generateSecret(): NetworkResult = NetworkResult.Success("mockSecret12345") + + 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) + } + + override suspend fun updateSharePermissionPreset( + id: String, + request: UpdateSharePermissionPresetRequest + ): NetworkResult { + val index = mockShares.indexOfFirst { it.id == id } + val current = + if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList()) + val updated = current.copy( + permissionPreset = request.permissionPreset, + lastUpdated = System.currentTimeMillis() + ) + if (index >= 0) mockShares[index] = updated + return NetworkResult.Success(updated) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt new file mode 100644 index 00000000..71eccd8f --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -0,0 +1,224 @@ +/* + * 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.UpdateSharePermissionPresetRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipientSecretRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest +import com.nextcloud.android.common.ui.share.model.api.share.Share +import 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" + private const val SECRET_ENDPOINT = "/ocs/v2.php/apps/sharing/api/v1/secret" + } + + override suspend fun fetchRecipients( + recipientTypeClasses: List?, + query: String, + limit: Int, + offset: Int + ): NetworkResult> { + val queryParams = buildString { + append("?query=$query&limit=$limit&offset=$offset") + recipientTypeClasses?.forEach { append("&recipientTypeClasses[]=$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/permission", + method = HttpMethod.PUT, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { body -> + json.decodeFromString>(body).ocs.data + } + + override suspend fun updateSharePermissionPreset( + id: String, + request: UpdateSharePermissionPresetRequest + ): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/permission/preset", + method = HttpMethod.PUT, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { body -> + json.decodeFromString>(body).ocs.data + } + + override suspend fun updateShareRecipientSecret( + id: String, + request: UpdateShareRecipientSecretRequest + ): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/recipient/secret", + method = HttpMethod.PUT, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { body -> + json.decodeFromString>(body).ocs.data + } + + override suspend fun generateSecret(): NetworkResult = + client.executeRequest( + endpoint = SECRET_ENDPOINT, + method = HttpMethod.GET + ) { body -> + json.decodeFromString>(body).ocs.data + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt new file mode 100644 index 00000000..51bd49d2 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt @@ -0,0 +1,94 @@ +/* + * 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.UpdateSharePermissionPresetRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipientSecretRequest +import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest +import com.nextcloud.android.common.ui.share.model.api.share.Share + +interface ShareRepository { + + suspend fun fetchRecipients( + recipientTypeClasses: List?, + 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 + + suspend fun updateSharePermissionPreset( + id: String, + request: UpdateSharePermissionPresetRequest + ): NetworkResult + + suspend fun updateShareRecipientSecret( + id: String, + request: UpdateShareRecipientSecretRequest + ): NetworkResult + + suspend fun generateSecret(): NetworkResult +} diff --git a/ui/src/main/res/drawable/ic_anyone.xml b/ui/src/main/res/drawable/ic_anyone.xml new file mode 100644 index 00000000..4e16a67b --- /dev/null +++ b/ui/src/main/res/drawable/ic_anyone.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/main/res/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..063b4096 --- /dev/null +++ b/ui/src/main/res/values/strings.xml @@ -0,0 +1,53 @@ + + + + OK + Cancel + + Create a new share + Share %s + + Settings + + Recipient + Clear recipient + + Custom link + The link can be changed to be easy to remember, but do not set it to something that is easy to guess. + Refresh link + + Not shared + No result + + Copied + Copy private link + Copy public link + Send + Share public link… + + Edit + Send email + Delete + Delete share? + This share will be permanently removed. + + Failed to fetch shares. + Failed to delete share. + Failed to create share. + Failed to update share. + Some settings couldn\'t be saved. Fix the highlighted fields before sharing. + + Set permission + Participants + Can… + Can view + Can edit + + Invited people + Anyone + \ No newline at end of file