diff --git a/build.gradle b/build.gradle
index f69b0dd9..fd2f19cf 100644
--- a/build.gradle
+++ b/build.gradle
@@ -9,6 +9,7 @@ buildscript {
ext {
daggerVersion = "2.60.1"
kotlinVersion = "2.4.10"
+ lifecycleVersion = "2.11.0"
jUnitVersion = "4.13.2"
androidXTestVersion = "1.3.0"
androidXEspressoTestVersion = "3.7.0"
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 9d159708..12c02be1 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -77,6 +77,7 @@
+
@@ -98,6 +99,7 @@
+
@@ -105,6 +107,11 @@
+
+
+
+
+
@@ -174,6 +181,7 @@
+
@@ -245,6 +253,7 @@
+
@@ -281,6 +290,7 @@
+
@@ -296,6 +306,7 @@
+
@@ -335,6 +346,11 @@
+
+
+
+
+
@@ -14904,6 +14920,11 @@
+
+
+
+
+
@@ -19369,6 +19390,11 @@
+
+
+
+
+
@@ -20874,6 +20900,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..d7f38168 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,27 @@ 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
+
+ // region lifecycle
+ api "androidx.lifecycle:lifecycle-viewmodel:$lifecycleVersion"
+ api "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVersion"
+ implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion"
+ // endregion
+
+ implementation("io.coil-kt.coil3:coil-compose:3.5.0")
+ implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
+ implementation("io.coil-kt.coil3:coil-svg:3.5.0")
implementation("com.vanniktech:ui:0.10.0")
@@ -60,6 +77,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..ff1e3b26
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.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.network.http
+
+import com.nextcloud.android.common.ui.BuildConfig
+import com.nextcloud.android.common.ui.network.auth.AuthInterceptor
+import com.nextcloud.android.common.ui.network.auth.ServerCredentials
+import com.nextcloud.android.common.ui.network.model.Meta
+import com.nextcloud.android.common.ui.network.model.NetworkResult
+import com.nextcloud.android.common.ui.network.model.Ocs
+import com.nextcloud.android.common.ui.network.model.OcsResponse
+import com.nextcloud.android.common.ui.network.serialization.OCSSerializer
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonPrimitive
+import okhttp3.OkHttpClient
+import okhttp3.RequestBody
+import java.util.concurrent.TimeUnit
+
+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"
+ private const val OCS_FAILURE = "failure"
+
+ 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('/')
+
+ private fun errorMeta(code: Int): Meta = Meta(status = OCS_FAILURE, statusCode = code, message = "")
+
+ suspend fun executeRequest(
+ endpoint: String,
+ method: HttpMethod,
+ body: RequestBody? = null,
+ parse: (String) -> T
+ ): NetworkResult = withContext(Dispatchers.IO) {
+ val request = buildOcsRequest("$baseUrl$endpoint", method, body)
+ debugLogger.logRequest(endpoint, method, body)
+ try {
+ val response = okHttpClient.newCall(request).execute()
+ val responseBody = response.body.string()
+
+ if (responseBody.isBlank()) {
+ val isError = !response.isSuccessful
+ debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = isError)
+ return@withContext if (isError) {
+ NetworkResult.ServerError(OcsResponse(Ocs(errorMeta(response.code), "")))
+ } else {
+ NetworkResult.Success(parse(responseBody))
+ }
+ }
+
+ val ocs = OCSSerializer.json
+ .decodeFromString>(responseBody)
+ .ocs
+ val meta = ocs.meta
+ val isError = !response.isSuccessful || meta.status != OCS_OK
+ debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = isError)
+
+ if (isError) {
+ val dataMessage = (ocs.data as? JsonPrimitive)?.takeIf { it.isString }?.content
+ val message = dataMessage?.takeIf { it.isNotBlank() } ?: meta.message
+ NetworkResult.ServerError(OcsResponse(Ocs(meta, message)))
+ } else {
+ NetworkResult.Success(parse(responseBody))
+ }
+ } 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..e0f046fe
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt
@@ -0,0 +1,449 @@
+/*
+ * 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.PaddingValues
+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.ExperimentalMaterial3Api
+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.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil3.ImageLoader
+import coil3.compose.setSingletonImageLoaderFactory
+import coil3.svg.SvgDecoder
+import com.nextcloud.android.common.ui.R
+import com.nextcloud.android.common.ui.component.ContentUnavailableView
+import com.nextcloud.android.common.ui.network.auth.ServerCredentials
+import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient
+import com.nextcloud.android.common.ui.share.component.PublicLinkIcon
+import com.nextcloud.android.common.ui.share.component.RecipientIcon
+import com.nextcloud.android.common.ui.share.component.bottomsheet.AddOrEditShareBottomSheet
+import com.nextcloud.android.common.ui.share.component.bottomsheet.QuickSharePermissionBottomSheet
+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.ShareEditorEntry
+import com.nextcloud.android.common.ui.share.model.ui.ShareItemOverlayState
+import com.nextcloud.android.common.ui.share.model.ui.ShareItemType
+import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState
+import com.nextcloud.android.common.ui.share.model.ui.label
+import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository
+import com.nextcloud.android.common.ui.share.viewmodel.ShareViewModel
+import com.nextcloud.android.common.ui.share.viewmodel.ShareViewModelFactory
+
+private val FIRST_ITEM_TOP_SPACING = 16.dp
+private val ITEM_SPACING = 2.dp
+
+@Composable
+private fun ShareScreen(
+ internalLink: String,
+ viewModel: ShareViewModel
+) {
+ val errorMessageId by viewModel.errorMessageId.collectAsStateWithLifecycle()
+ val screenState by viewModel.state.collectAsStateWithLifecycle()
+ val activeShare by viewModel.activeShare.collectAsStateWithLifecycle()
+ val permissionPresets by viewModel.permissionPresets.collectAsStateWithLifecycle()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val resources = LocalResources.current
+
+ LaunchedEffect(errorMessageId) {
+ errorMessageId?.let {
+ snackbarHostState.showSnackbar(resources.getString(it))
+ viewModel.updateErrorMessage(null)
+ }
+ }
+
+ Scaffold(
+ floatingActionButton = {
+ FloatingActionButton(
+ onClick = { viewModel.createDraftShare() },
+ ) {
+ 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 -> ShareList(
+ isRefreshing = false,
+ shares = emptyList(),
+ permissionPresets = permissionPresets,
+ paddingValues = paddingValues,
+ viewModel = viewModel
+ )
+
+ is ShareScreenState.Loaded -> ShareList(
+ isRefreshing = state.refreshing,
+ shares = state.shares,
+ permissionPresets = permissionPresets,
+ paddingValues = paddingValues,
+ viewModel = viewModel
+ )
+ }
+ }
+
+ activeShare.shareOrNull?.let { activeShareObject ->
+ AddOrEditShareBottomSheet(
+ share = activeShareObject,
+ internalLink = internalLink,
+ viewModel = viewModel,
+ permissionPresets = permissionPresets,
+ onDismissDraft = { draftShare ->
+ viewModel.deleteShare(draftShare.id)
+ viewModel.setActiveShare(null)
+ }
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ShareList(
+ isRefreshing: Boolean,
+ shares: List,
+ permissionPresets: List,
+ paddingValues: PaddingValues,
+ viewModel: ShareViewModel
+) {
+ val context = LocalContext.current
+
+ PullToRefreshBox(
+ isRefreshing = isRefreshing,
+ onRefresh = viewModel::refreshShares,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (shares.isEmpty()) {
+ item {
+ Box(modifier = Modifier.fillParentMaxSize()) {
+ ContentUnavailableView(
+ iconId = R.drawable.ic_person_add,
+ title = stringResource(R.string.share_view_empty_title),
+ )
+ }
+ }
+ }
+
+ itemsIndexed(shares, key = { _, share -> share.id }) { index, share ->
+ Spacer(modifier = Modifier.height(if (index == 0) FIRST_ITEM_TOP_SPACING else ITEM_SPACING))
+
+ ShareItem(
+ share = share,
+ title = share.getHeadline(context, shares),
+ type = ShareItemType.type(index, shares.lastIndex),
+ permissionPresets = permissionPresets,
+ onSelectShare = { selected ->
+ viewModel.setActiveShare(selected, ShareEditorEntry.EDIT)
+ },
+ onCustomizeShare = { selected ->
+ viewModel.setActiveShare(selected, ShareEditorEntry.CUSTOMIZE_PERMISSION)
+ },
+ onChangePreset = { selected, preset ->
+ viewModel.updatePermissionPreset(selected.id, preset, updateActiveShare = false)
+ },
+ onDeleteShare = { viewModel.deleteShare(it.id) },
+ onSendEmail = { selected ->
+ viewModel.setActiveShare(selected, ShareEditorEntry.SEND_EMAIL)
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ShareItem(
+ share: Share,
+ title: String,
+ type: ShareItemType,
+ permissionPresets: List,
+ onSelectShare: (Share) -> Unit,
+ onCustomizeShare: (Share) -> Unit,
+ onChangePreset: (Share, String) -> Unit,
+ onDeleteShare: (Share) -> Unit,
+ onSendEmail: (Share) -> Unit
+) {
+ var overlayState by rememberSaveable(share.id) { mutableStateOf(ShareItemOverlayState.None) }
+ val haptics = LocalHapticFeedback.current
+ val presetOptions = PermissionPresetOption.optionsFor(share, permissionPresets)
+ val selectedPresetOption = PermissionPresetOption.from(share.permissionPreset, permissionPresets)
+
+ when (overlayState) {
+ ShareItemOverlayState.QuickShare -> {
+ QuickSharePermissionBottomSheet(
+ options = presetOptions,
+ selectedOption = selectedPresetOption,
+ onOptionSelected = { option ->
+ overlayState = ShareItemOverlayState.None
+ val presetClass = option.presetClass
+ if (presetClass != null) {
+ onChangePreset(share, presetClass)
+ } else {
+ onCustomizeShare(share)
+ }
+ },
+ onDismiss = { overlayState = ShareItemOverlayState.None }
+ )
+ }
+
+ ShareItemOverlayState.DeleteConfirmation -> {
+ DeleteShareConfirmationDialog(
+ onConfirm = {
+ overlayState = ShareItemOverlayState.None
+ onDeleteShare(share)
+ },
+ onDismiss = { overlayState = ShareItemOverlayState.None }
+ )
+ }
+
+ else -> Unit
+ }
+
+ ListItem(
+ modifier = Modifier
+ .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 = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ leadingContent = {
+ Box(
+ modifier = Modifier.size(24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ val icon = share.recipients.firstOrNull()?.icon
+ if (icon != null) {
+ RecipientIcon(icon = icon, modifier = Modifier.size(24.dp))
+ } else {
+ PublicLinkIcon(modifier = Modifier.size(24.dp))
+ }
+ }
+ },
+ supportingContent = {
+ val chipHorizontalPadding = 10.dp
+ val selectedLabel = selectedPresetOption.label()
+
+ 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
+ ) {
+ Box(contentAlignment = Alignment.CenterStart) {
+ presetOptions.forEach { option ->
+ Text(
+ text = option.label(),
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ modifier = Modifier
+ .alpha(0f)
+ .clearAndSetSemantics {}
+ )
+ }
+
+ Text(
+ text = selectedLabel,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1
+ )
+ }
+
+ 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(
+ viewModelStoreOwner: ViewModelStoreOwner,
+ sourceId: String,
+ internalLink: String,
+ credentials: ServerCredentials,
+ colorScheme: ColorScheme
+) {
+ val factory = ShareViewModelFactory(sourceId) {
+ ShareRemoteRepository(NextcloudHttpClient.create(credentials))
+ }
+ val viewModel = ViewModelProvider.create(viewModelStoreOwner, factory)[ShareViewModel::class]
+
+ setContent {
+ setSingletonImageLoaderFactory { context ->
+ ImageLoader.Builder(context)
+ .components { add(SvgDecoder.Factory()) }
+ .build()
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ content = {
+ ShareScreen(internalLink, viewModel)
+ }
+ )
+ }
+}
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/avatar/ShareAvatarRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/avatar/ShareAvatarRepository.kt
new file mode 100644
index 00000000..6316f315
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/avatar/ShareAvatarRepository.kt
@@ -0,0 +1,37 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.share.avatar
+
+import android.util.Log
+import com.nextcloud.android.common.ui.network.auth.ServerCredentials
+import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient
+import com.nextcloud.android.common.ui.network.model.dataOrElse
+import com.nextcloud.android.common.ui.share.model.api.share.Share
+import com.nextcloud.android.common.ui.share.model.api.source.Source
+import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class ShareAvatarRepository(private val credentials: ServerCredentials) {
+ companion object {
+ private const val TAG = "ShareAvatarRepository"
+ private const val SHARES_PAGE_SIZE = 3
+ }
+
+ suspend fun fetchShareAvatars(sourceId: String): List? = withContext(Dispatchers.IO) {
+ val client = NextcloudHttpClient.create(credentials)
+ val shareRepository = ShareRemoteRepository(client)
+ val result = shareRepository.fetchShares(
+ filterSourceTypeValue = sourceId,
+ limit = SHARES_PAGE_SIZE,
+ filterSourceTypeClass = Source.NODE_SOURCE_CLASS,
+ lastShareID = null
+ )
+ return@withContext result.dataOrElse { Log.e(TAG, "failed to fetch avatars") }
+ }
+}
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..e747b63e
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt
@@ -0,0 +1,124 @@
+/*
+ * 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.focus.onFocusChanged
+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 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
+ }
+
+ 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)
+ )
+
+ CustomLinkTokenField(
+ initialToken = recipient.secret.value ?: "",
+ prefix = prefix,
+ onGenerateSecret = onGenerateSecret,
+ onCommit = onTokenChange
+ )
+ }
+}
+
+@Composable
+private fun CustomLinkTokenField(
+ initialToken: String,
+ prefix: String,
+ onGenerateSecret: suspend () -> String?,
+ onCommit: (String) -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ var token by remember(initialToken) { mutableStateOf(initialToken) }
+ var committedToken by remember(initialToken) { mutableStateOf(initialToken) }
+ var wasFocused by remember(initialToken) { mutableStateOf(false) }
+
+ fun commit() {
+ if (token == committedToken || token.isBlank()) return
+ committedToken = token
+ onCommit(token)
+ }
+
+ OutlinedTextField(
+ value = token,
+ onValueChange = { newValue -> token = newValue.take(MAX_TOKEN_LENGTH) },
+ label = { Text(prefix) },
+ singleLine = true,
+ trailingIcon = {
+ IconButton(
+ onClick = {
+ scope.launch {
+ val generated = onGenerateSecret() ?: return@launch
+ token = generated
+ committedToken = generated
+ onCommit(generated)
+ }
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = stringResource(R.string.share_view_custom_link_refresh)
+ )
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .onFocusChanged { focusState ->
+ if (wasFocused && !focusState.isFocused) commit()
+ wasFocused = focusState.isFocused
+ }
+ )
+}
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt
new file mode 100644
index 00000000..05521c60
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt
@@ -0,0 +1,221 @@
+/*
+ * 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.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+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.InputChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.svg.SvgDecoder
+import com.nextcloud.android.common.ui.R
+import com.nextcloud.android.common.ui.share.viewmodel.ShareViewModel
+import com.nextcloud.android.common.ui.share.model.api.icon.Icon
+import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient
+import com.nextcloud.android.common.ui.share.model.api.share.Share
+
+private val FIELD_PADDING = 12.dp
+private val CHIP_SPACING = 8.dp
+private val CHIP_AVATAR_SIZE = 20.dp
+private val CHIP_CLOSE_ICON_SIZE = 18.dp
+private val RESULT_ICON_SIZE = 20.dp
+
+@Composable
+fun SelectRecipientField(
+ share: Share,
+ viewModel: ShareViewModel
+) {
+ val selected = share.invitedRecipients
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ RecipientChips(
+ recipients = selected,
+ onRemove = { recipient ->
+ viewModel.removeRecipient(
+ id = share.id,
+ clazz = recipient.clazz,
+ value = recipient.value,
+ instance = recipient.instance
+ )
+ }
+ )
+
+ RecipientSearch(share = share, selected = selected, viewModel = viewModel)
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun RecipientChips(recipients: List, onRemove: (Recipient) -> Unit) {
+ if (recipients.isEmpty()) return
+
+ FlowRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = FIELD_PADDING),
+ horizontalArrangement = Arrangement.spacedBy(CHIP_SPACING)
+ ) {
+ recipients.forEach { recipient ->
+ InputChip(
+ selected = false,
+ onClick = { onRemove(recipient) },
+ label = { Text(recipient.displayName) },
+ avatar = {
+ recipient.icon?.let { RecipientIcon(icon = it, modifier = Modifier.size(CHIP_AVATAR_SIZE)) }
+ },
+ trailingIcon = {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(R.string.share_view_select_recipient_clear),
+ modifier = Modifier.size(CHIP_CLOSE_ICON_SIZE)
+ )
+ }
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun RecipientSearch(share: Share, selected: List, viewModel: ShareViewModel) {
+ val query by viewModel.searchQuery.collectAsStateWithLifecycle()
+ var expanded by rememberSaveable { mutableStateOf(false) }
+ val results by viewModel.recipientSearchResults.collectAsStateWithLifecycle()
+ val suggestions = results.filterNot { result -> selected.any { it.isSameAs(result) } }
+
+ ExposedDropdownMenuBox(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(FIELD_PADDING),
+ expanded = expanded && query.isNotBlank(),
+ onExpandedChange = { expanded = it }
+ ) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = {
+ 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 = suggestions,
+ onSelect = { recipient ->
+ viewModel.addRecipient(share.id, recipient.clazz, recipient.value, recipient.instance)
+ viewModel.onSearchQueryChanged("")
+ 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(RESULT_ICON_SIZE)
+ )
+ }
+ },
+ text = { Text(recipient.displayName) },
+ onClick = { onSelect(recipient) }
+ )
+ }
+ }
+}
+
+// TODO: can this come from backend?
+@Composable
+fun PublicLinkIcon(modifier: Modifier = Modifier) {
+ Icon(
+ painter = painterResource(R.drawable.ic_link),
+ contentDescription = "public link icon",
+ tint = MaterialTheme.colorScheme.onSurface,
+ modifier = modifier
+ )
+}
+
+@Composable
+fun RecipientIcon(icon: Icon, modifier: Modifier = Modifier) {
+ val isDark = isSystemInDarkTheme()
+ 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,
+ )
+ } else {
+ // reserve the same space even with no image
+ Box(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..8b51b58f
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt
@@ -0,0 +1,569 @@
+/*
+ * 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.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+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.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.ModalBottomSheetProperties
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.nextcloud.android.common.ui.R
+import com.nextcloud.android.common.ui.share.viewmodel.ShareViewModel
+import com.nextcloud.android.common.ui.share.component.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.ActiveShareState
+import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption
+import com.nextcloud.android.common.ui.share.model.ui.ShareCategory
+import com.nextcloud.android.common.ui.share.model.ui.ShareEditorEntry
+import com.nextcloud.android.common.ui.share.model.ui.label
+import com.nextcloud.android.common.ui.share.repository.MockShareRepository
+import kotlinx.coroutines.launch
+
+private const val CUSTOM_SELECTION = "custom"
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AddOrEditShareBottomSheet(
+ share: Share,
+ internalLink: String,
+ viewModel: ShareViewModel,
+ permissionPresets: List = emptyList(),
+ onDismissDraft: (Share) -> Unit = {}
+) {
+ val entry by viewModel.editorEntry.collectAsStateWithLifecycle()
+ var dismissAllowed by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ val sheetState = rememberModalBottomSheetState(
+ skipPartiallyExpanded = true,
+ confirmValueChange = { value -> dismissAllowed || value != SheetValue.Hidden }
+ )
+ val categories = remember { ShareCategory.entries.toList() }
+ var selectedCategory by rememberSaveable(share.id) {
+ mutableStateOf(if (share.belongsAnyoneTab) ShareCategory.Anyone else ShareCategory.Invited)
+ }
+ var showAdvancedSettings by rememberSaveable(share.id) { mutableStateOf(entry == ShareEditorEntry.SEND_EMAIL) }
+ val context = LocalContext.current
+ val propertyErrors by viewModel.propertyErrors.collectAsStateWithLifecycle()
+ val pendingProperties by viewModel.pendingProperties.collectAsStateWithLifecycle()
+ val hasPropertyErrors = propertyErrors.isNotEmpty()
+ val sendEnabled = !hasPropertyErrors && pendingProperties.isEmpty()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ ModalBottomSheet(
+ onDismissRequest = {},
+ sheetState = sheetState,
+ dragHandle = null,
+ properties = ModalBottomSheetProperties(shouldDismissOnBackPress = false),
+ containerColor = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = share.title(context),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+
+ IconButton(onClick = {
+ dismissAllowed = true
+ scope.launch {
+ sheetState.hide()
+ if (share.shareState == ShareState.DRAFT) onDismissDraft(share) else viewModel.setActiveShare(
+ null
+ )
+ }
+ }) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "close bottom sheet",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ ShareCategorySelector(
+ share = share,
+ categories = categories,
+ selectedCategory = selectedCategory,
+ onCategorySelected = { category ->
+ selectedCategory = category
+ viewModel.selectCategory(category, share)
+ }
+ )
+
+ if (selectedCategory == ShareCategory.Invited) {
+ SelectRecipientField(share, viewModel)
+ }
+
+ PermissionsView(
+ share = share,
+ entry = entry,
+ permissionPresets = permissionPresets,
+ viewModel = viewModel
+ )
+
+ BasicSettingsSection(
+ share = share,
+ viewModel = viewModel
+ )
+
+ AdvancedSettingsSection(
+ share = share,
+ isExpanded = showAdvancedSettings,
+ onToggle = { showAdvancedSettings = !showAdvancedSettings },
+ viewModel = viewModel
+ )
+
+ if (share.canSend) {
+ ActionButtons(
+ share = share,
+ internalLink = internalLink,
+ category = selectedCategory,
+ sendEnabled = sendEnabled,
+ viewModel = viewModel
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ShareCategorySelector(
+ share: Share,
+ categories: List,
+ selectedCategory: ShareCategory,
+ onCategorySelected: (ShareCategory) -> Unit
+) {
+ // only allow user to select between taps if it is draft share
+ if (share.shareState != ShareState.DRAFT) return
+
+ SingleChoiceSegmentedButtonRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ ) {
+ categories.forEachIndexed { index, category ->
+ SegmentedButton(
+ selected = selectedCategory == category,
+ onClick = { onCategorySelected(category) },
+ shape = SegmentedButtonDefaults.itemShape(index = index, count = categories.size),
+ icon = {
+ Icon(painter = painterResource(category.iconId), contentDescription = "")
+ }
+ ) {
+ Text(stringResource(category.titleId))
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun PermissionsView(
+ share: Share,
+ entry: ShareEditorEntry,
+ permissionPresets: List,
+ viewModel: ShareViewModel
+) {
+ var userSelection by rememberSaveable(share.id) {
+ mutableStateOf(
+ if (entry == ShareEditorEntry.CUSTOMIZE_PERMISSION) CUSTOM_SELECTION else null
+ )
+ }
+
+ val selectedPreset = when (userSelection) {
+ null -> share.permissionPreset
+ CUSTOM_SELECTION -> null
+ else -> userSelection
+ }
+
+ PermissionPresetDropdown(
+ options = PermissionPresetOption.optionsFor(share, permissionPresets),
+ selectedOption = PermissionPresetOption.from(selectedPreset, permissionPresets),
+ onOptionSelected = { option ->
+ userSelection = option.presetClass ?: CUSTOM_SELECTION
+ option.presetClass?.let { viewModel.updatePermissionPreset(share.id, it, true) }
+ }
+ )
+
+ if (selectedPreset != null) return
+
+ share.permissions.forEach { permission ->
+ key(permission.clazz) {
+ var checked by remember(permission.clazz, permission.enabled) {
+ 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(
+ options: List,
+ selectedOption: PermissionPresetOption,
+ onOptionSelected: (PermissionPresetOption) -> Unit
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ modifier = Modifier.padding(12.dp),
+ expanded = expanded,
+ onExpandedChange = { expanded = it }
+ ) {
+ OutlinedTextField(
+ value = selectedOption.label(),
+ 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 }
+ ) {
+ options.forEach { option ->
+ DropdownMenuItem(
+ text = { Text(option.label()) },
+ onClick = {
+ expanded = false
+ onOptionSelected(option)
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun BasicSettingsSection(
+ share: Share,
+ viewModel: ShareViewModel
+) {
+ if (!share.isBasicSectionAvailable) {
+ return
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ ) {
+ share.basicProperties.forEach { property ->
+ key(property.clazz) {
+ SharePropertyView(
+ shareId = share.id,
+ property = property,
+ viewModel = viewModel
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AdvancedSettingsSection(
+ share: Share,
+ isExpanded: Boolean,
+ onToggle: () -> Unit,
+ viewModel: ShareViewModel
+) {
+ if (!share.isAdvancedSectionAvailable) {
+ return
+ }
+
+ CollapsibleShareSection(
+ label = stringResource(R.string.share_view_advanced_settings),
+ isExpanded = isExpanded,
+ onToggle = onToggle
+ ) {
+ share.advancedProperties.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
+) {
+ val localClipboard = LocalClipboard.current
+ val scope = rememberCoroutineScope()
+ val isPreparingLink by viewModel.isPreparingLink.collectAsStateWithLifecycle()
+ val activeShare by viewModel.activeShare.collectAsStateWithLifecycle()
+
+ LaunchedEffect(activeShare) {
+ val ready = (activeShare as? ActiveShareState.Activating)?.share ?: return@LaunchedEffect
+ ready.getClipEntry(internalLink, category)?.let { localClipboard.setClipEntry(it) }
+ viewModel.onLinkCopied()
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Button(
+ onClick = {
+ if (category == ShareCategory.Anyone) {
+ viewModel.prepareLinkForCopy(share.id)
+ } else {
+ scope.launch {
+ share.getClipEntry(internalLink, category)?.let { localClipboard.setClipEntry(it) }
+ }
+ }
+ },
+ enabled = !isPreparingLink && (category != ShareCategory.Anyone || sendEnabled),
+ modifier = Modifier.weight(1f),
+ shape = RoundedCornerShape(10.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ ) {
+ if (isPreparingLink) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ } else {
+ Icon(
+ painter = painterResource(R.drawable.ic_link),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ Text(
+ text = stringResource(category.copyLinkTitleId),
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+
+ 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)
+ },
+ enabled = sendEnabled,
+ 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(), "preview-source", SavedStateHandle())
+ )
+ }
+}
+
+private val previewIcon =
+ com.nextcloud.android.common.ui.share.model.api.icon.Icon(svg = "