diff --git a/build.gradle b/build.gradle index e91c2350..bdbc915b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022-2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: MIT */ @@ -9,6 +10,12 @@ buildscript { ext { daggerVersion = "2.60.1" kotlinVersion = "2.4.20" + lifecycleVersion = "2.11.0" + composeUiToolingVersion = "1.12.0" + coilVersion = "3.6.2" + okHttpVersion = "5.5.0" + composeBomVersion = "2026.08.00" + kotlinxSerializationVersion = "1.11.0" jUnitVersion = "4.13.2" androidXTestVersion = "1.3.0" androidXEspressoTestVersion = "3.7.0" @@ -60,10 +67,13 @@ subprojects { spotless { kotlin { target '**/*.kt' - ktlint() + ktlint().editorConfigOverride([ + 'ktlint_function_naming_ignore_when_annotated_with': 'Composable' + ]) } } detekt { buildUponDefaultConfig = true + config.setFrom(rootProject.file('detekt.yml')) } } diff --git a/core/src/main/java/com/nextcloud/android/common/core/utils/DateFormatter.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/DateFormatter.kt index c5da7255..b6ce886e 100644 --- a/core/src/main/java/com/nextcloud/android/common/core/utils/DateFormatter.kt +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/DateFormatter.kt @@ -18,9 +18,7 @@ import kotlin.text.toInt /** * Helper implementation for date formatting. */ -class DateFormatter( - private val context: Context -) { +class DateFormatter(private val context: Context) { private val sdfDays: SimpleDateFormat private val sdfMonths: SimpleDateFormat private val sdfYears: SimpleDateFormat diff --git a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/AccountReceiverCallback.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/AccountReceiverCallback.kt index c655777f..efe42368 100644 --- a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/AccountReceiverCallback.kt +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/AccountReceiverCallback.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: MIT */ diff --git a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemApp.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemApp.kt index 3d89bcd4..14e8df42 100644 --- a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemApp.kt +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemApp.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: MIT */ @@ -15,9 +16,7 @@ package com.nextcloud.android.common.core.utils.ecosystem * allow compatibility with different flavours (Play Store, F-Droid, QA, beta/dev versions, etc.). * */ -enum class EcosystemApp( - val packageNames: List -) { +enum class EcosystemApp(val packageNames: List) { FILES( listOf( "com.nextcloud.client", // generic, gplay, huawei diff --git a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt index a134a03f..57f79c2b 100644 --- a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/EcosystemManager.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: MIT */ @@ -28,9 +29,7 @@ import java.util.regex.Pattern * - Redirecting to Play Store if app not installed * - Receiving account info from intents with callback support */ -class EcosystemManager( - private val activity: Activity -) { +class EcosystemManager(private val activity: Activity) { companion object { private const val TAG = "EcosystemManager" @@ -63,10 +62,7 @@ class EcosystemManager( * e.g. "abc@example.cloud.com" */ @Suppress("TooGenericExceptionCaught", "ReturnCount") - fun openApp( - app: EcosystemApp, - accountName: String? - ) { + fun openApp(app: EcosystemApp, accountName: String?) { Log.d(TAG, "open app, package name: ${app.packageNames}, account name: $accountName") // check account name emptiness @@ -163,10 +159,7 @@ class EcosystemManager( * */ @Suppress("ReturnCount") - fun receiveAccount( - intent: Intent?, - callback: AccountReceiverCallback - ) { + fun receiveAccount(intent: Intent?, callback: AccountReceiverCallback) { Log.d(TAG, "receive account started") if (intent == null) { diff --git a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/LinkHelper.kt b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/LinkHelper.kt index 23561573..91b3417b 100644 --- a/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/LinkHelper.kt +++ b/core/src/main/java/com/nextcloud/android/common/core/utils/ecosystem/LinkHelper.kt @@ -25,11 +25,7 @@ object LinkHelper { * @param string packageName or url-encoded search string * @param search false -> show app corresponding to packageName; true -> open search for string */ - fun openAppStore( - string: String, - search: Boolean = false, - context: Context - ) { + fun openAppStore(string: String, search: Boolean = false, context: Context) { var suffix = (if (search) "search?q=" else "details?id=") + string val intent = Intent(Intent.ACTION_VIEW, "market://$suffix".toUri()) try { diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index b38b3f67..aa0b34f4 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -2,6 +2,7 @@ diff --git a/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNamePatternTest.kt b/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNamePatternTest.kt index 1950d2a9..c861d2d9 100644 --- a/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNamePatternTest.kt +++ b/core/src/test/java/com/nextcloud/android/common/core/utils/AccountNamePatternTest.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: MIT */ diff --git a/core/src/test/java/com/nextcloud/android/common/core/utils/EcosystemAppTest.kt b/core/src/test/java/com/nextcloud/android/common/core/utils/EcosystemAppTest.kt index 628009d3..2ad03650 100644 --- a/core/src/test/java/com/nextcloud/android/common/core/utils/EcosystemAppTest.kt +++ b/core/src/test/java/com/nextcloud/android/common/core/utils/EcosystemAppTest.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: MIT */ diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 00000000..5279506f --- /dev/null +++ b/detekt.yml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +# Applied on top of the detekt default config (buildUponDefaultConfig = true). +comments: + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + UndocumentedPublicClass: + active: false + UndocumentedPublicFunction: + active: false + UndocumentedPublicProperty: + active: false + +complexity: + LabeledExpression: + active: false diff --git a/gradle/verification-keyring.keys b/gradle/verification-keyring.keys index 7a6489f1..7d6aa8af 100644 --- a/gradle/verification-keyring.keys +++ b/gradle/verification-keyring.keys @@ -1304,6 +1304,21 @@ fW1AkBVEk6siyL8PXfxmj9ev3H9xiQVLyJ6HpdHTLVjHjFkgNOLd =R7zg -----END PGP PUBLIC KEY BLOCK----- +pub CFAE163B64AC9189 +uid JetBrains Compose Team + +sub 57CE36BB68F1BC57 +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEYYx3eRYJKwYBBAHaRw8BAQdAV7zh1T+xL7mD2O63rTIvRfQ9kwL2Gvq/Q6PD +9apCe2K0LkpldEJyYWlucyBDb21wb3NlIFRlYW0gPGNvbXBvc2VAamV0YnJhaW5z +LmNvbT64OARhjHd5EgorBgEEAZdVAQUBAQdA/AOwflH/rum1g1gvFPBcqhvFOcN8 +0VUb7OqoENSzUD4DAQgHiH4EGBYKACYWIQQgcjpjmbwGAVQoOzfPrhY7ZKyRiQUC +YYx3eQIbDAUJA8JnAAAKCRDPrhY7ZKyRiSq0AQCEwGRGd5MOTT2kKJf9CphX0LOs +qVBBSNvzoloV04wzywD+P4TmIz/CsigszDQy8yl1/rGvszo7gaUwnfnqN61JHgA= +=Y19b +-----END PGP PUBLIC KEY BLOCK----- + pub D041CAD2E452550F uid Deanna @@ -2389,6 +2404,34 @@ xV+8+asS+ID6jUp5FJ4izH6U90j4iiBfqIu+UEw+0gvD+TVqpqcj5pNlgU45HA== =Cl60 -----END PGP PUBLIC KEY BLOCK----- +pub 1188B69F6D6259CA +uid Chris Banes + +sub 0888B86856F9D71A +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsBNBF2hcBkBCAC2H5WcFoeByKBUAjRDjmP+5P6FRsZjLe6c1wy7G1ha6/EQUVK4 +gZUZYE9W7l/4QKHvAu4PvFWdD+5FXGZuB/2kw348CtabAlJTehm1QlPt5//ODkxB +fWPPz3uHBo5PQJZuLpStQn+aEkTTHK6Sk5033e6fa7mV5X/c8pTmTzkunG58NFbj +5VSVbks4pbafKoMY7rSN0/I2hEApCjB9tx/7DJuQ9gbaGhmabnhBTnwHXEV1/hIF +369lfiqeoeqDMOKj99C6KFD/NPRrRLfoPRpqL3LPsPp7P+TzyZN2q89Xqn2ysyI9 +jDtrlssEXLskU5kA8fVa179V/QR6QtrJ29m/ABEBAAG0HENocmlzIEJhbmVzIDxj +aHJpc0BiYW5lcy5tZT7OwE0EXaFwGQEIALaSv8Um4LBbyPM8PIfcSwl5sqBQal1n +w7461u8gGAi3uikn62Q7IGO3SRRuJcR6hkY5Dyq1qPwEPmcBjK0Nlx1NTtk4TwDT +ZsxexNV40qQ+BuZhCb0NmiQBRBY7o3GiFa8mqcNeTNnIxvqMYInIYcvHps3r77QN +Y8PZznfDNpJkuD/vvoFnDjncdaQd+Kia2gqAFHQMw6DnV1oCyPx38CqK10rw3DPt +yYo/sVo67mVLJZvoXx255DbKNATKSrhiuCIsK9r+lIvY9zXoI2cJFsNA1QSiDkt1 +yjW1us89+LMt5dVCanq0bfhprU4J3ZYqzUS1sVAekVY2jRw/ssidX+sAEQEAAcLA +fAQYAQgAJhYhBGS5sJ8WSqC/iHQuthGItp9tYlnKBQJdoXAZAhsMBQkDwmcAAAoJ +EBGItp9tYlnKg94H/2GsZGvWK8+dA4y0AmCtP4B6/WAUonGBMmUH1Ax8GaPKIQuC +ePBbQI5v2dGBqUOTjTh/qcpwfTHSMSWFRTv2HZDP82gY/tAHJrKZ14+Pr1JB4Nvd +PIj60dKFfyveC3t8TJ4kVjKvvDzGY7oHsZ1OmW1Nytsbe6Taj57UY8v67zpLRCEb +ZHIEo4hYtehEIlCWi7M2qJmXtWK/wU3bw0C8KL4Kun968+0005iOdQ/Iig4oI4K/ +UW2HkfQM1AwuwJlsc8uXUvH4A3kkVHTGnvvi4aTMtDWPKpdNu/eufWwuMrS6Gxdu +v7wjtN+wTGesv0OYCfxHScTi49XbBbU3PBq+pxQ= +=KzdM +-----END PGP PUBLIC KEY BLOCK----- + pub 15C71C0A4E0B8EDD uid Matthias Bläsing @@ -3529,6 +3572,34 @@ AGg2/CyrKszDpsR8R04jHL/ZAxrodA4Awc3BTpNxQDPfdRPc8w== =SIQO -----END PGP PUBLIC KEY BLOCK----- +pub 4D176DC503FB4267 +uid Colin White + +sub 5686B45C142551D3 +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBF1EtnUBCAChtyYd/4eMAxTz5DVmO+8QOrTA1cf9bprQhtXD5pVbw8/IGKN+ +EqXmvt7AGy+4O633g7ec5iyirwCfEP+4YDv8k1LOvY9C5+tOwfK+FxAPRVc1AAB5 +q23x4yMI7aDdvN52/jqpREeBBWPcrnEIET68RApayALenjUP6Kx5K0ge9aU8uaSB +HsVpHSr05L1nKuuqh0LDNXIYDLCpo0LnwPNfLrJ2xT6gNTwHBY5mncDy4m01rdxD +3to/s2Uu/9Xkfz3+BJKRD2kr5txiqoW69H/qsg7u8tTOr9FhB1T21pjFZsdpzbOq +cKd7UyD0RpyQYHmTeUIhKSI0Su7pKT+RTL9BABEBAAG0IUNvbGluIFdoaXRlIDxj +b2xpbkBjb2xpbndoaXRlLm1lPrkBDQRdRLZ1AQgAxOP3klOByUo1Kyl1O6rZqj9e +yts/oXtuDTISfcRkZyg6fmkhT4kpd4xLSP3xHvRwJugybyTedDHzXXCOSjl3EFfU +GaKJuMqSKs5YjQOWk8S9BegAPeiq6PGV6gbHZQ3Xqy+XE+TLy5N96zu7th/YJraE +NpS79sj//mJQE2d49YrxhZwtMj64X0B8/mDmED+D2cPXAoLxNh//LaV26gpOC9yV +YTXrCq7ODPE5LyoljhBmPZxoapcn/39V6UvVSG8Dq6R5QdahDgNnCEjVPtSzGtbC +B4zpSv/LvZkm8gKlaxUcra5clXL7p4NWGx1C1Ap4+H6U2h53lC7FuYl8k9mBNwAR +AQABiQE2BBgBCAAgAhsMFiEE3yXTxIaP7BdxgvrWTRdtxQP7QmcFAl1P4JcACgkQ +TRdtxQP7QmcrkAgAiSZ2VRgHTY8SV4dXXjYBO6WM2Yzamt4bO5Uqflw7gPO44AtJ +h+Pn89iz37RnGL+bAq2MCNsgV4G+KaLJrE0c/rt/szC7UmwEJmy4DcH5B80wzBvo +2kpN1Uyr7+1+IgHogL6HjLxccoBpawpm0AnhNYrd2ml7STBCfNmilztWGrTkerb/ +2qOY7PmgWEad7whM0OEj1OE+M2IYZUga3gxf0B94GtQNwmI4xv5qXGskTFAlM9ko +94NfvOxEkgCGtnicN4Md74od0POEVrZxBpXuJGt60XVGvOr1iLmlpypWo+PkHhZe +Wt45mNAFy6vraTYYCkGXQKqx1kxg+OurYgIFgw== +=zwEJ +-----END PGP PUBLIC KEY BLOCK----- + pub 530AA5F25C25011F uid Gary Gregory @@ -3689,6 +3760,34 @@ oLIHbQ4IMkRivPgSpuxw53Hofe7A =9YZ+ -----END PGP PUBLIC KEY BLOCK----- +pub 597852B0B140F0BC +uid AndroidSVG (Key for signing maven packages) + +sub E0A468B5546CD437 +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFFDPRgBCAC7Z/0fkdEkWVCD5BCDrIj/YOZBFOLHAZWLiVhyX0iDO1fhJcj7 +8PKGiv+tnLz/FC2gk6ZJCbwbAlxb5hXLvPWe1SAn6KjOvqNKx+qZ9LNMo4dCqlBp ++tAB0yUQVAorl33vHJWV93WaiKwsFpl6Gzr4i5Fmo4G5isXaDMO6NXbNbx45tmfU +V1RPI3EcIl/h5jNyRlJqDkoNz3TmgVpt9UO9hH2cGsejdj0c+56/4+/OE+IH15pt +w+oRt1dmGFxLlSA6k60rIzc27stABX0ZXi0WoPKGW8psRnZKNVf8/9d8IaGQivWu +sytoVGsaUmIjlJnZYEIJtk7xOMrWqmJdqsHvABEBAAG0SkFuZHJvaWRTVkcgKEtl +eSBmb3Igc2lnbmluZyBtYXZlbiBwYWNrYWdlcykgPGFuZHJvaWRzdmdmZWVkYmFj +a0BnbWFpbC5jb20+uQENBFFDPRgBCADYyjiQvAnrxZ7j3sGXw0ExSbUm0ccOwZn5 +ENsUTOjVvHmqSEQEyWQea6JXN1+8EH3LUCTww8s4bZCEtqIDM0qbmrjAD5vqmFhq +U/Kcqu+nd4eHaObpvc8BiEHZCtGMkXx1Xcgs8j8RPBVGJgYe+6KAiyAPIoZdBxXv +L40X/wWGRfQvW3CIfq/9SzTsHQCpA60iEegOjImsfLyYbxoskM/W4qrjIIMki+qQ +3ePJLc+UIazRnHtHduW+vfEEqmHlVhceup4q6EdXpNP5YZ8w7KytMlXCklMtNapz +PQ+N4Ohpidr2IKuPud4ej9dr2NHJNip8xRaTpeDNkvZPHMieXX1nABEBAAGJAR4E +GAECAAkFAlFDPRgCGwwACgkQWXhSsLFA8LxY2QfzBI7TCC3MSigxB+xu6w2cb5yM +2cD22EJuKuW84F/84CHIyzQW2NvqZYu8Ekn6Za6VzX5DUUhNpKMsQTjs0Y1TckXA +P5inx7bqPn5FBKnaeJVzOhX0gWmr/ahgUW1NKd10jmIoCfhB6JmEbmiFzx4uwDgL +RttGzWtyHoxUE/HXC49WUtf2Txaz3Fmsv2w2wEKR6G7/+TMtOEJm4FKpr1dhVsaH +Y03O49Sl4CDbseRODa7osGgh1aB0dBf3e/cB7F79bNyE2Zi2RW7DE9QrrLmThkWQ +WUf7+NXNXdibtyh/xufvR7JXwrK1msSv7jeBKnO/4bwuQjrBr7WD6h7RxUl1 +=ES/b +-----END PGP PUBLIC KEY BLOCK----- + pub 59A252FB1199D873 uid Tagir Valeev @@ -4539,6 +4638,18 @@ ymOZIduHXX1X+KzlRZTXvv4tSFnIQ0mWY1ySiOJQJS2WABVwFpFc8rECm6eN =z4dc -----END PGP PUBLIC KEY BLOCK----- +pub 793FD5751A0F0780 +sub 59EF9FBBED4216F5 +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xjMEZzuZjRYJKwYBBAHaRw8BAQdA/FAoxAeXKY80R8UZ35NNr06SMFqC3Pv/LtP2 +sZS+7N7OOARnO5mNEgorBgEEAZdVAQUBAQdAcU+jcl01TAmiI2RmtjPv4c/sW4nE +TaR06bnQoHFL3HMDAQgHwn4EGBYKACYWIQQdIX+Ede7p8Zq43Wt5P9V1Gg8HgAUC +ZzuZjQIbDAUJEswDAAAKCRB5P9V1Gg8HgJgoAP9sdWX3itgWylKDw70h5G3ZLNSt +zqKyEx/3NURzCWXtUwD/dkcAz5qsu2LGQynLaRXgNI4ZVgTb2zUzZM0TbshIzAo= +=BMAA +-----END PGP PUBLIC KEY BLOCK----- + pub 79752DB6C966F0B8 sub AC9F6F1991913E30 -----BEGIN PGP PUBLIC KEY BLOCK----- diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 35515104..6cf0a25d 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -44,6 +44,9 @@ + + + @@ -75,6 +78,7 @@ + @@ -103,6 +107,11 @@ + + + + + @@ -175,6 +184,7 @@ + @@ -246,6 +256,7 @@ + @@ -282,6 +293,7 @@ + @@ -297,6 +309,7 @@ + @@ -336,6 +349,11 @@ + + + + + @@ -15699,6 +15717,14 @@ + + + + + + + + @@ -15707,11 +15733,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -20940,6 +20997,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 edbcce1c..6c26adc3 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -45,6 +45,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.2.2' 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"> * SPDX-FileCopyrightText: 2022-2023 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2023 Andy Scherzinger * SPDX-FileCopyrightText: 2023 Stefan Niedermann @@ -31,6 +32,7 @@ import com.nextcloud.android.common.ui.util.extensions.addSystemBarPaddings class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding + @Suppress("LongMethod") override fun onCreate(savedInstanceState: Bundle?) { val style = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) enableEdgeToEdge(style, style) @@ -85,6 +87,26 @@ class MainActivity : AppCompatActivity() { material.colorMaterialButtonPrimaryBorderless(negativeButton) } + binding.testApiBtn.setOnClickListener { + val baseUrl = + binding.baseUrl.text + ?.toString() + .orEmpty() + val username = + binding.username.text + ?.toString() + .orEmpty() + val token = + binding.token.text + ?.toString() + .orEmpty() + mainViewModel.testPredefinedStatuses(baseUrl, username, token) + } + + mainViewModel.apiTestResult.observe(this) { result -> + Toast.makeText(this, result, Toast.LENGTH_LONG).show() + } + setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) mainViewModel.color.observe(this) { applyTheme(it) } diff --git a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt index 457600c7..51f21bd5 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 @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022-2023 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2023 Stefan Niedermann * SPDX-License-Identifier: MIT @@ -9,7 +10,40 @@ 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..79ea423f 100644 --- a/sample/src/main/res/layout/activity_main.xml +++ b/sample/src/main/res/layout/activity_main.xml @@ -2,6 +2,7 @@ @@ -193,6 +194,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 213b129e..4d03eb60 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -1,12 +1,14 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022-2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: MIT */ 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 +17,7 @@ plugins { android { defaultConfig { - minSdk = 21 + minSdk = 26 compileSdk = 37 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -23,6 +25,7 @@ android { buildFeatures { compose = true + buildConfig = true } compileOptions { @@ -45,12 +48,27 @@ android { } dependencies { + // region Jetpack Compose + implementation "androidx.compose.ui:ui-tooling-preview:$composeUiToolingVersion" + debugImplementation "androidx.compose.ui:ui-tooling:$composeUiToolingVersion" kapt "org.jetbrains.kotlin:kotlin-metadata-jvm:${kotlinVersion}" - implementation(platform("androidx.compose:compose-bom:2026.08.00")) + implementation(platform("androidx.compose:compose-bom:$composeBomVersion")) 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:$coilVersion") + implementation("io.coil-kt.coil3:coil-network-okhttp:$coilVersion") + implementation("io.coil-kt.coil3:coil-svg:$coilVersion") implementation("com.vanniktech:ui:0.10.0") @@ -60,6 +78,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:$okHttpVersion")) + implementation("com.squareup.okhttp3:okhttp") + implementation("com.squareup.okhttp3:logging-interceptor") + // endregion + + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion") + 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/color/ColorUtil.kt b/ui/src/main/java/com/nextcloud/android/common/ui/color/ColorUtil.kt index 48d19696..10d7d340 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/color/ColorUtil.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/color/ColorUtil.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022-2023 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2022-2023 Andy Scherzinger * SPDX-FileCopyrightText: 2022 Álvaro Brey @@ -20,94 +21,71 @@ import javax.inject.Inject import kotlin.math.roundToInt class ColorUtil - @Inject - constructor( - private val context: Context - ) { - @ColorInt - fun getNullSafeColor( - color: String?, - @ColorInt fallbackColor: Int - ): Int = color.parseColorOrFallback { fallbackColor } +@Inject +constructor(private val context: Context) { + @ColorInt + fun getNullSafeColor(color: String?, @ColorInt fallbackColor: Int): Int = + color.parseColorOrFallback { fallbackColor } - @ColorInt - fun getNullSafeColorWithFallbackRes( - color: String?, - @ColorRes fallbackColorRes: Int - ): Int = color.parseColorOrFallback { ContextCompat.getColor(context, fallbackColorRes) } + @ColorInt + fun getNullSafeColorWithFallbackRes(color: String?, @ColorRes fallbackColorRes: Int): Int = + color.parseColorOrFallback { ContextCompat.getColor(context, fallbackColorRes) } - @ColorInt - fun getTextColor( - colorText: String?, - @ColorInt backgroundColor: Int - ): Int = colorText.parseColorOrFallback { getForegroundColorForBackgroundColor(backgroundColor) } + @ColorInt + fun getTextColor(colorText: String?, @ColorInt backgroundColor: Int): Int = + colorText.parseColorOrFallback { getForegroundColorForBackgroundColor(backgroundColor) } - @ColorInt - fun getForegroundColorForBackgroundColor( - @ColorInt color: Int - ): Int = - if (isDarkBackground(color)) { - Color.WHITE - } else { - ContextCompat.getColor(context, R.color.grey_900) - } + @ColorInt + fun getForegroundColorForBackgroundColor(@ColorInt color: Int): Int = if (isDarkBackground(color)) { + Color.WHITE + } else { + ContextCompat.getColor(context, R.color.grey_900) + } - fun isDarkBackground( - @ColorInt color: Int - ): Boolean { - val hsl = FloatArray(HSL_SIZE) - ColorUtils.RGBToHSL(Color.red(color), Color.green(color), Color.blue(color), hsl) + fun isDarkBackground(@ColorInt color: Int): Boolean { + val hsl = FloatArray(HSL_SIZE) + ColorUtils.RGBToHSL(Color.red(color), Color.green(color), Color.blue(color), hsl) - return hsl[INDEX_LIGHTNESS] < LIGHTNESS_DARK_THRESHOLD - } + return hsl[INDEX_LIGHTNESS] < LIGHTNESS_DARK_THRESHOLD + } - fun setLightness( - @ColorInt color: Int, - lightness: Float - ): Int { - require(lightness in 0.0..1.0) { "Lightness must be between 0 and 1" } - val hsl = FloatArray(HSL_SIZE) - ColorUtils.RGBToHSL(Color.red(color), Color.green(color), Color.blue(color), hsl) + fun setLightness(@ColorInt color: Int, lightness: Float): Int { + require(lightness in 0.0..1.0) { "Lightness must be between 0 and 1" } + val hsl = FloatArray(HSL_SIZE) + ColorUtils.RGBToHSL(Color.red(color), Color.green(color), Color.blue(color), hsl) - hsl[INDEX_LIGHTNESS] = lightness + hsl[INDEX_LIGHTNESS] = lightness - return ColorUtils.HSLToColor(hsl) - } + return ColorUtils.HSLToColor(hsl) + } - fun colorToHexString( - @ColorInt color: Int - ): String = String.format(null, "#%06X", HEX_WHITE and color) + fun colorToHexString(@ColorInt color: Int): String = String.format(null, "#%06X", HEX_WHITE and color) - @ColorInt - private fun String?.parseColorOrFallback(fallback: () -> Int): Int = - if (this?.isNotBlank() == true) { - try { - Color.parseColor(this) - } catch (e: IllegalArgumentException) { - Log.w(TAG, "Invalid color: $this", e) - fallback() - } - } else { - fallback() - } + @ColorInt + private fun String?.parseColorOrFallback(fallback: () -> Int): Int = if (this?.isNotBlank() == true) { + try { + Color.parseColor(this) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Invalid color: $this", e) + fallback() + } + } else { + fallback() + } - @ColorInt - fun adjustOpacity( - color: Int, - opacity: Float - ): Int = - Color.argb( - (Color.alpha(color) * opacity).roundToInt(), - Color.red(color), - Color.green(color), - Color.blue(color) - ) + @ColorInt + fun adjustOpacity(color: Int, opacity: Float): Int = Color.argb( + (Color.alpha(color) * opacity).roundToInt(), + Color.red(color), + Color.green(color), + Color.blue(color) + ) - companion object { - private const val HSL_SIZE: Int = 3 - private const val INDEX_LIGHTNESS: Int = 2 - private const val LIGHTNESS_DARK_THRESHOLD: Float = 0.6f - private const val HEX_WHITE = 0xFFFFFF - private val TAG = ColorUtil::class.simpleName - } + companion object { + private const val HSL_SIZE: Int = 3 + private const val INDEX_LIGHTNESS: Int = 2 + private const val LIGHTNESS_DARK_THRESHOLD: Float = 0.6f + private const val HEX_WHITE = 0xFFFFFF + private val TAG = ColorUtil::class.simpleName } +} 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..4fae6511 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/component/ContentUnavailableView.kt @@ -0,0 +1,90 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming", "UnusedPrivateMember") + +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.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R + +private val CONTENT_PADDING = 24.dp +private val ICON_SIZE = 64.dp +private val ICON_TITLE_SPACING = 16.dp +private val TITLE_DESCRIPTION_SPACING = 4.dp + +@Composable +fun ContentUnavailableView(title: String, description: String? = null, iconId: Int? = null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(CONTENT_PADDING), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + iconId?.let { + Image( + painter = painterResource(iconId), + modifier = Modifier.size(ICON_SIZE), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant), + contentDescription = null + ) + + Spacer(modifier = Modifier.height(ICON_TITLE_SPACING)) + } + + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + + description?.let { + Spacer(modifier = Modifier.height(TITLE_DESCRIPTION_SPACING)) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } +} + +@PreviewLightDark +@Composable +private fun ContentUnavailableViewPreview() { + MaterialTheme { + ContentUnavailableView( + iconId = R.drawable.ic_error, + title = stringResource(R.string.share_view_fetch_error_message), + description = stringResource(R.string.share_view_fetch_error_description) + ) + } +} 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..052371c2 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/PredefinedStatus.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..40bcca8c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusRepository.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..af24de26 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/AuthInterceptor.kt @@ -0,0 +1,51 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..ceed33bf --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/ServerCredentials.kt @@ -0,0 +1,11 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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) 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..e13f2643 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/HttpMethod.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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") +} 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..4f1a2629 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt @@ -0,0 +1,100 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 = "") + + @Suppress("TooGenericExceptionCaught") + 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..cd6f3e3d --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClientLogger.kt @@ -0,0 +1,95 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 successStatusCodes = 200..299 + private val redirectStatusCodes = 300..399 + + 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 successStatusCodes -> "🟢" + code in redirectStatusCodes -> "🟡" + else -> "🔴" + } + + @Suppress("TooGenericExceptionCaught") + 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..946988a2 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/RequestBuilder.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..3b69edce --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..af559cae --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..bcefafa5 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/serialization/OCSSerializer.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..b0d5b9b1 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt @@ -0,0 +1,322 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming", "LongMethod") + +package com.nextcloud.android.common.ui.share + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +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.listSaver +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.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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.ShareListItem +import com.nextcloud.android.common.ui.share.component.ShareListShimmer +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.bottomsheet.RecipientPermissionBottomSheet +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.ShareItemType +import com.nextcloud.android.common.ui.share.model.ui.ShareListItemActions +import com.nextcloud.android.common.ui.share.model.ui.ShareListItemState +import com.nextcloud.android.common.ui.share.model.ui.ShareOverlay +import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState +import com.nextcloud.android.common.ui.share.model.ui.recipientSummary +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 +private val FAB_CLEARANCE = 88.dp +private val FAB_PROGRESS_SIZE = 24.dp +private val FAB_PROGRESS_STROKE = 2.dp + +private val ExpandedShareIdsSaver = listSaver, String>( + save = { it.toList() }, + restore = { it.toSet() } +) + +@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 isCreatingDraft by viewModel.isCreatingDraft.collectAsStateWithLifecycle() + val permissionPresets by viewModel.permissionPresets.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val resources = LocalResources.current + var overlay by rememberSaveable(stateSaver = ShareOverlay.Saver) { + mutableStateOf(ShareOverlay.None) + } + + LaunchedEffect(errorMessageId) { + errorMessageId?.let { + snackbarHostState.showSnackbar(resources.getString(it)) + viewModel.updateErrorMessage(null) + } + } + + Scaffold( + floatingActionButton = { + FloatingActionButton( + onClick = { viewModel.createDraftShare() } + ) { + if (isCreatingDraft) { + CircularProgressIndicator( + modifier = Modifier.size(FAB_PROGRESS_SIZE), + strokeWidth = FAB_PROGRESS_STROKE, + color = LocalContentColor.current + ) + } else { + Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add") + } + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = Color.Transparent + ) { paddingValues -> + when (val state = screenState) { + is ShareScreenState.Loading -> ShareListShimmer(modifier = Modifier.padding(paddingValues)) + + else -> ShareList( + state = state, + permissionPresets = permissionPresets, + paddingValues = paddingValues, + viewModel = viewModel, + onShowOverlay = { overlay = it } + ) + } + } + + activeShare.shareOrNull?.let { activeShareObject -> + AddOrEditShareBottomSheet( + share = activeShareObject, + internalLink = internalLink, + viewModel = viewModel, + permissionPresets = permissionPresets + ) + } + + ShareItemOverlay( + overlay = overlay, + shares = (screenState as? ShareScreenState.Loaded)?.shares.orEmpty(), + permissionPresets = permissionPresets, + onDismiss = { overlay = ShareOverlay.None }, + viewModel = viewModel + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShareList( + state: ShareScreenState, + permissionPresets: List, + paddingValues: PaddingValues, + viewModel: ShareViewModel, + onShowOverlay: (ShareOverlay) -> Unit +) { + val context = LocalContext.current + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val shares = (state as? ShareScreenState.Loaded)?.shares.orEmpty() + var expandedShareIds by rememberSaveable(stateSaver = ExpandedShareIdsSaver) { mutableStateOf(emptySet()) } + val actions = remember(viewModel, onShowOverlay) { + ShareListItemActions( + onSelectShare = { share -> viewModel.setActiveShare(share, ShareEditorEntry.EDIT) }, + onToggleExpanded = { share -> + expandedShareIds = if (share.id in expandedShareIds) { + expandedShareIds - share.id + } else { + expandedShareIds + share.id + } + }, + onShowOverlay = onShowOverlay + ) + } + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = viewModel::refreshShares, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(rememberNestedScrollInteropConnection()), + contentPadding = PaddingValues(bottom = FAB_CLEARANCE), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (shares.isEmpty()) { + item { + Box(modifier = Modifier.fillParentMaxSize()) { + SharePlaceholder(state) + } + } + } + + itemsIndexed(shares, key = { _, share -> share.id }) { index, share -> + Spacer(modifier = Modifier.height(if (index == 0) FIRST_ITEM_TOP_SPACING else ITEM_SPACING)) + + ShareListItem( + state = ShareListItemState( + share = share, + title = if (share.hasMultipleRecipients) { + recipientSummary(share.recipients) + } else { + share.getHeadline(context, shares) + }, + type = ShareItemType.type(index, shares.lastIndex), + isExpanded = share.id in expandedShareIds + ), + permissionPresets = permissionPresets, + actions = actions + ) + } + } + } +} + +@Composable +private fun ShareItemOverlay( + overlay: ShareOverlay, + shares: List, + permissionPresets: List, + onDismiss: () -> Unit, + viewModel: ShareViewModel +) { + val share = overlay.shareId?.let { id -> shares.firstOrNull { it.id == id } } ?: return + + when (overlay) { + is ShareOverlay.QuickShare -> QuickSharePermissionBottomSheet( + options = PermissionPresetOption.optionsFor(share, permissionPresets), + selectedOption = PermissionPresetOption.from(share.permissionPreset, permissionPresets), + onOptionSelected = { option -> + onDismiss() + val presetClass = option.presetClass + if (presetClass != null) { + viewModel.updatePermissionPreset(share.id, presetClass, updateActiveShare = false) + } else { + viewModel.setActiveShare(share, ShareEditorEntry.CUSTOMIZE_PERMISSION) + } + }, + onDismiss = onDismiss + ) + + is ShareOverlay.RecipientPermission -> { + val recipient = share.recipients.firstOrNull { + it.clazz == overlay.recipientClass && + it.value == overlay.recipientValue && + it.instance == overlay.recipientInstance + } ?: return + + RecipientPermissionBottomSheet( + share = share, + recipient = recipient, + permissionPresets = permissionPresets, + viewModel = viewModel, + onDismiss = onDismiss + ) + } + + ShareOverlay.None -> Unit + } +} + +@Composable +private fun SharePlaceholder(state: ShareScreenState) { + if (state is ShareScreenState.Error) { + ContentUnavailableView( + iconId = R.drawable.ic_error, + title = stringResource(R.string.share_view_fetch_error_message), + description = stringResource(R.string.share_view_fetch_error_description) + ) + return + } + + ContentUnavailableView( + iconId = R.drawable.ic_person_add_filled, + title = stringResource(R.string.share_view_empty_title) + ) +} + +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..6206067b --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/avatar/ShareAvatarRepository.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..d3e4ce70 --- /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 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +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 + +private val SECTION_VERTICAL_PADDING = 16.dp + +@Composable +fun CollapsibleShareSection(label: String, isExpanded: Boolean, onToggle: () -> Unit, content: @Composable () -> Unit) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = SECTION_VERTICAL_PADDING)) { + 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..373543ba --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/CustomLink.kt @@ -0,0 +1,123 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +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(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/DeleteShareButton.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareButton.kt new file mode 100644 index 00000000..8fe2b721 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/DeleteShareButton.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.stringResource +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.component.dialog.DeleteShareConfirmationDialog + +private val BUTTON_VERTICAL_PADDING = 8.dp +private val ICON_SIZE = 18.dp +private val ICON_SPACING = 8.dp + +@Composable +fun DeleteShareButton(onDelete: () -> Unit) { + var showConfirmation by remember { mutableStateOf(false) } + + Row(modifier = Modifier.fillMaxWidth().padding(vertical = BUTTON_VERTICAL_PADDING)) { + TextButton( + onClick = { showConfirmation = true }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(ICON_SIZE) + ) + + Spacer(modifier = Modifier.width(ICON_SPACING)) + + Text(stringResource(R.string.share_view_delete_action)) + } + } + + if (!showConfirmation) return + + DeleteShareConfirmationDialog( + onConfirm = { + showConfirmation = false + onDelete() + }, + onDismiss = { showConfirmation = false } + ) +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientAvatar.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientAvatar.kt new file mode 100644 index 00000000..e29b58ab --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientAvatar.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient + +private val PUBLIC_LINK_ICON_PADDING = 3.dp + +@Composable +fun RecipientAvatar(recipient: Recipient, size: Dp, modifier: Modifier = Modifier) { + val avatarModifier = modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + + val icon = recipient.icon + if (icon == null) { + PublicLinkIcon(modifier = avatarModifier.padding(PUBLIC_LINK_ICON_PADDING)) + return + } + + RecipientIcon(icon = icon, modifier = avatarModifier) +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientAvatarStack.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientAvatarStack.kt new file mode 100644 index 00000000..63f868b6 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/RecipientAvatarStack.kt @@ -0,0 +1,64 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +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.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient + +private const val MAX_AVATARS = 3 + +private val AVATAR_SIZE = 26.dp +private val AVATAR_OVERLAP = 9.dp +private val AVATAR_RING_WIDTH = 1.5.dp +private val OVERFLOW_START_PADDING = 4.dp + +@Composable +fun RecipientAvatarStack(recipients: List, modifier: Modifier = Modifier) { + val displayed = recipients.take(MAX_AVATARS) + val overflow = recipients.size - displayed.size + + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + displayed.forEachIndexed { index, recipient -> + RecipientAvatar( + recipient = recipient, + size = AVATAR_SIZE, + modifier = Modifier + .offset(x = -AVATAR_OVERLAP * index) + .zIndex((displayed.size - index).toFloat()) + .border(AVATAR_RING_WIDTH, MaterialTheme.colorScheme.surface, CircleShape) + ) + } + + if (overflow > 0) { + Text( + text = stringResource(R.string.share_view_recipients_overflow, overflow), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .offset(x = -AVATAR_OVERLAP * displayed.size) + .padding(start = OVERFLOW_START_PADDING) + ) + } + } +} 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..dca34421 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SelectRecipientField.kt @@ -0,0 +1,215 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +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.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 +import com.nextcloud.android.common.ui.share.viewmodel.ShareViewModel + +private val FIELD_VERTICAL_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(), + 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(vertical = FIELD_VERTICAL_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) } + ) + } + } +} + +@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/ShareListItem.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareListItem.kt new file mode 100644 index 00000000..3e7ee4bd --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareListItem.kt @@ -0,0 +1,153 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +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.ShareListItemActions +import com.nextcloud.android.common.ui.share.model.ui.ShareListItemState +import com.nextcloud.android.common.ui.share.model.ui.ShareOverlay + +private const val ITEM_WIDTH_FRACTION = 0.9f +private const val CONTAINER_ALPHA = 0.5f + +private val LEADING_ICON_SIZE = 24.dp + +@Composable +fun ShareListItem(state: ShareListItemState, permissionPresets: List, actions: ShareListItemActions) { + val share = state.share + + Column( + modifier = Modifier + .fillMaxWidth(ITEM_WIDTH_FRACTION) + .clip(state.type.getShape()) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = CONTAINER_ALPHA)) + ) { + ListItem( + modifier = Modifier.clickable { actions.onSelectShare(share) }, + headlineContent = { + Text( + text = state.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + leadingContent = { ShareItemLeadingContent(share = share, isExpanded = state.isExpanded) }, + supportingContent = { + SharePermissionChip( + options = PermissionPresetOption.optionsFor(share, permissionPresets), + selectedOption = PermissionPresetOption.from(share.permissionPreset, permissionPresets), + onClick = { actions.onShowOverlay(ShareOverlay.QuickShare(share.id)) } + ) + }, + trailingContent = { ShareItemTrailingIcon(state = state, actions = actions) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) + + AnimatedVisibility(visible = state.isExpanded && share.hasMultipleRecipients) { + ShareRecipientList( + share = share, + permissionPresets = permissionPresets, + actions = actions + ) + } + } +} + +@Composable +private fun ShareRecipientList(share: Share, permissionPresets: List, actions: ShareListItemActions) { + Column { + share.recipients.forEach { recipient -> + key(recipient.clazz, recipient.value, recipient.instance) { + ShareRecipientRow( + share = share, + recipient = recipient, + permissionPresets = permissionPresets, + onEditPermissions = { + actions.onShowOverlay( + ShareOverlay.RecipientPermission( + shareId = share.id, + recipientClass = recipient.clazz, + recipientValue = recipient.value, + recipientInstance = recipient.instance + ) + ) + } + ) + } + } + } +} + +@Composable +private fun ShareItemLeadingContent(share: Share, isExpanded: Boolean) { + if (share.hasMultipleRecipients) { + if (!isExpanded) { + RecipientAvatarStack(recipients = share.recipients) + } + return + } + + Box( + modifier = Modifier.size(LEADING_ICON_SIZE), + contentAlignment = Alignment.Center + ) { + val recipient = share.recipients.firstOrNull() + if (recipient != null) { + RecipientAvatar(recipient = recipient, size = LEADING_ICON_SIZE) + } else { + PublicLinkIcon(modifier = Modifier.size(LEADING_ICON_SIZE)) + } + } +} + +@Composable +private fun ShareItemTrailingIcon(state: ShareListItemState, actions: ShareListItemActions) { + if (!state.share.hasMultipleRecipients) { + ShareTrailingIcon(onClick = { actions.onSelectShare(state.share) }) + return + } + + val descriptionId = if (state.isExpanded) { + R.string.share_view_recipients_collapse + } else { + R.string.share_view_recipients_expand + } + + ShareTrailingIcon( + contentDescription = stringResource(descriptionId), + isPointingDown = state.isExpanded, + onClick = { actions.onToggleExpanded(state.share) } + ) +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareListShimmer.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareListShimmer.kt new file mode 100644 index 00000000..3bac4535 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareListShimmer.kt @@ -0,0 +1,154 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.share.model.ui.ShareItemType + +private const val SHIMMER_ROW_COUNT = 6 +private const val ITEM_WIDTH_FRACTION = 0.9f +private const val CONTAINER_ALPHA = 0.5f +private const val HEADLINE_WIDTH_FRACTION = 0.5f +private const val CHIP_CORNER_PERCENT = 50 +private const val SHIMMER_MIN_ALPHA = 0.12f +private const val SHIMMER_MAX_ALPHA = 0.28f +private const val SHIMMER_ANIMATION_DURATION_MILLIS = 900 + +private val FIRST_ITEM_TOP_SPACING = 16.dp +private val ITEM_SPACING = 2.dp +private val ROW_PADDING = 16.dp +private val CONTENT_SPACING = 16.dp +private val LEADING_ICON_SIZE = 24.dp +private val TRAILING_ICON_SIZE = 48.dp +private val HEADLINE_HEIGHT = 16.dp +private val LINE_SPACING = 8.dp +private val CHIP_WIDTH = 90.dp +private val CHIP_HEIGHT = 20.dp +private val BAR_CORNER_RADIUS = 4.dp + +@Composable +fun ShareListShimmer(modifier: Modifier = Modifier) { + val alpha = rememberShimmerAlpha() + + LazyColumn( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + userScrollEnabled = false + ) { + items(SHIMMER_ROW_COUNT) { index -> + Spacer(modifier = Modifier.height(if (index == 0) FIRST_ITEM_TOP_SPACING else ITEM_SPACING)) + ShareListItemShimmer( + type = ShareItemType.type(index, SHIMMER_ROW_COUNT - 1), + alpha = alpha + ) + } + } +} + +@Composable +private fun rememberShimmerAlpha(): Float { + val transition = rememberInfiniteTransition(label = "shareListShimmer") + val alpha by transition.animateFloat( + initialValue = SHIMMER_MIN_ALPHA, + targetValue = SHIMMER_MAX_ALPHA, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = SHIMMER_ANIMATION_DURATION_MILLIS, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "shareListShimmerAlpha" + ) + return alpha +} + +@Composable +private fun ShareListItemShimmer(type: ShareItemType, alpha: Float) { + Column( + modifier = Modifier + .fillMaxWidth(ITEM_WIDTH_FRACTION) + .clip(type.getShape()) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = CONTAINER_ALPHA)) + .padding(ROW_PADDING) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + ShimmerBox( + modifier = Modifier + .size(LEADING_ICON_SIZE) + .clip(CircleShape), + alpha = alpha + ) + + Spacer(modifier = Modifier.width(CONTENT_SPACING)) + + Column(modifier = Modifier.weight(1f)) { + ShimmerBox( + modifier = Modifier + .fillMaxWidth(HEADLINE_WIDTH_FRACTION) + .height(HEADLINE_HEIGHT) + .clip(RoundedCornerShape(BAR_CORNER_RADIUS)), + alpha = alpha + ) + + Spacer(modifier = Modifier.height(LINE_SPACING)) + + ShimmerBox( + modifier = Modifier + .width(CHIP_WIDTH) + .height(CHIP_HEIGHT) + .clip(RoundedCornerShape(percent = CHIP_CORNER_PERCENT)), + alpha = alpha + ) + } + + Spacer(modifier = Modifier.width(CONTENT_SPACING)) + + ShimmerBox( + modifier = Modifier + .size(TRAILING_ICON_SIZE) + .clip(CircleShape), + alpha = alpha + ) + } + } +} + +@Composable +private fun ShimmerBox(modifier: Modifier, alpha: Float) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)) + ) +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SharePermissionChip.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SharePermissionChip.kt new file mode 100644 index 00000000..fb99ba47 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/SharePermissionChip.kt @@ -0,0 +1,88 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption +import com.nextcloud.android.common.ui.share.model.ui.label + +private val CHIP_HORIZONTAL_PADDING = 10.dp +private val CHIP_VERTICAL_PADDING = 2.dp +private val CHIP_ICON_SIZE = 18.dp +private val CHIP_ICON_SPACING = 2.dp +private const val CHIP_BACKGROUND_ALPHA = 0.08f + +@Composable +fun SharePermissionChip( + options: List, + selectedOption: PermissionPresetOption, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .offset(x = -CHIP_HORIZONTAL_PADDING) + .clip(RoundedCornerShape(percent = 50)) + .clickable { onClick() } + .background(MaterialTheme.colorScheme.primary.copy(alpha = CHIP_BACKGROUND_ALPHA)) + .padding(horizontal = CHIP_HORIZONTAL_PADDING, vertical = CHIP_VERTICAL_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + Box(contentAlignment = Alignment.CenterStart) { + options.forEach { option -> + Text( + text = option.label(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + modifier = Modifier + .alpha(0f) + .clearAndSetSemantics {} + ) + } + + Text( + text = selectedOption.label(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1 + ) + } + + Spacer(modifier = Modifier.width(CHIP_ICON_SPACING)) + + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(CHIP_ICON_SIZE) + ) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareRecipientRow.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareRecipientRow.kt new file mode 100644 index 00000000..5f14d926 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareRecipientRow.kt @@ -0,0 +1,64 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +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.share.Share +import com.nextcloud.android.common.ui.share.model.ui.effectivePermissionsFor +import com.nextcloud.android.common.ui.share.model.ui.presetOption +import com.nextcloud.android.common.ui.share.model.ui.recipientPresetOptions + +private val ROW_START_PADDING = 24.dp +private val AVATAR_SIZE = 24.dp + +@Composable +fun ShareRecipientRow( + share: Share, + recipient: Recipient, + permissionPresets: List, + onEditPermissions: () -> Unit +) { + ListItem( + modifier = Modifier + .clickable { onEditPermissions() } + .padding(start = ROW_START_PADDING), + headlineContent = { + Text( + text = recipient.displayName, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + leadingContent = { RecipientAvatar(recipient = recipient, size = AVATAR_SIZE) }, + supportingContent = { + SharePermissionChip( + options = share.recipientPresetOptions(permissionPresets), + selectedOption = share.effectivePermissionsFor(recipient).presetOption(permissionPresets), + onClick = onEditPermissions + ) + }, + trailingContent = { ShareTrailingIcon() }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) +} 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..64ff7132 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareSwitch.kt @@ -0,0 +1,51 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +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, enabled: Boolean = true) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled + ) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareTrailingIcon.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareTrailingIcon.kt new file mode 100644 index 00000000..bd97cb41 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/ShareTrailingIcon.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +package com.nextcloud.android.common.ui.share.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.unit.dp + +private val TOUCH_TARGET_SIZE = 48.dp +private const val POINTING_DOWN_ROTATION = 90f +private const val POINTING_END_ROTATION = 0f + +@Composable +fun ShareTrailingIcon( + contentDescription: String? = null, + isPointingDown: Boolean = false, + onClick: (() -> Unit)? = null +) { + val box = Modifier + .size(TOUCH_TARGET_SIZE) + .clip(CircleShape) + .let { if (onClick == null) it else it.clickable(onClick = onClick) } + + Box(modifier = box, contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.rotate( + if (isPointingDown) POINTING_DOWN_ROTATION else POINTING_END_ROTATION + ) + ) + } +} 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..316478ef --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/AddOrEditShareBottomSheet.kt @@ -0,0 +1,583 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming", "LongMethod", "UnusedPrivateMember") + +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.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.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.component.CollapsibleShareSection +import com.nextcloud.android.common.ui.share.component.CustomLink +import com.nextcloud.android.common.ui.share.component.DeleteShareButton +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.component.property.propertyErrorMessage +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 com.nextcloud.android.common.ui.share.viewmodel.ShareViewModel +import kotlinx.coroutines.launch + +private const val CUSTOM_SELECTION = "custom" + +private val SHEET_CONTENT_PADDING = 16.dp +private val CLOSE_BUTTON_END_PADDING = 4.dp +private val CATEGORY_VERTICAL_PADDING = 8.dp +private val PRESET_VERTICAL_PADDING = 12.dp +private val ACTION_BUTTONS_VERTICAL_PADDING = 16.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddOrEditShareBottomSheet( + share: Share, + internalLink: String, + viewModel: ShareViewModel, + permissionPresets: List = emptyList() +) { + val entry by viewModel.editorEntry.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val dismissSheet = { viewModel.dismissActiveShare(share.id) } + 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(false) } + 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() + + ModalBottomSheet( + onDismissRequest = dismissSheet, + sheetState = sheetState, + sheetGesturesEnabled = false, + dragHandle = null, + properties = ModalBottomSheetProperties(shouldDismissOnClickOutside = false), + containerColor = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + ShareSheetHeader( + title = share.title(context), + onClose = { scope.launch { sheetState.hide() }.invokeOnCompletion { dismissSheet() } } + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SHEET_CONTENT_PADDING) + ) { + 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, + propertyErrors = propertyErrors, + viewModel = viewModel + ) + + AdvancedSettingsSection( + share = share, + isExpanded = showAdvancedSettings, + onToggle = { showAdvancedSettings = !showAdvancedSettings }, + propertyErrors = propertyErrors, + viewModel = viewModel + ) + + if (share.canSend) { + ActionButtons( + share = share, + internalLink = internalLink, + category = selectedCategory, + sendEnabled = sendEnabled, + viewModel = viewModel + ) + } + } + } + } +} + +@Composable +private fun ShareSheetHeader(title: String, onClose: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = SHEET_CONTENT_PADDING, + end = CLOSE_BUTTON_END_PADDING, + top = PRESET_VERTICAL_PADDING, + bottom = CATEGORY_VERTICAL_PADDING + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.weight(1f) + ) + + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "close bottom sheet", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@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(vertical = CATEGORY_VERTICAL_PADDING) + ) { + 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 + .fillMaxWidth() + .padding(vertical = PRESET_VERTICAL_PADDING), + 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, propertyErrors: Map, viewModel: ShareViewModel) { + if (!share.isBasicSectionAvailable) { + return + } + + Column(modifier = Modifier.fillMaxWidth()) { + share.basicProperties.forEach { property -> + key(property.clazz) { + SharePropertyView( + property = property, + errorMessage = propertyErrorMessage(propertyErrors, property.clazz), + onValueChange = { value -> viewModel.updateProperty(share.id, property.clazz, value) } + ) + } + } + } +} + +@Composable +private fun AdvancedSettingsSection( + share: Share, + isExpanded: Boolean, + onToggle: () -> Unit, + propertyErrors: Map, + viewModel: ShareViewModel +) { + val isDeletable = share.shareState != ShareState.DRAFT + if (!share.isAdvancedSectionAvailable && !isDeletable) { + return + } + + CollapsibleShareSection( + label = stringResource(R.string.share_view_advanced_settings), + isExpanded = isExpanded, + onToggle = onToggle + ) { + share.advancedProperties.forEach { property -> + key(property.clazz) { + SharePropertyView( + property = property, + errorMessage = propertyErrorMessage(propertyErrors, property.clazz), + onValueChange = { value -> viewModel.updateProperty(share.id, property.clazz, value) } + ) + } + } + + share.customLinkRecipient?.let { + CustomLink( + recipient = it, + onGenerateSecret = { viewModel.generateSecret() }, + onTokenChange = { token -> + viewModel.updateRecipientSecret(share.id, it, token) + } + ) + } + + if (isDeletable) { + DeleteShareButton(onDelete = { viewModel.deleteShare(share.id) }) + } + } +} + +@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(vertical = ACTION_BUTTONS_VERTICAL_PADDING), + 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 = "") + +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, + advanced = false, + value = "" + ) + ), + permissions = listOf( + Permission( + clazz = "read", + displayName = "Read", + priority = 10, + presets = listOf("view", "edit"), + enabled = true + ), + Permission( + clazz = "update", + displayName = "Update", + priority = 20, + presets = listOf("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..d3d9dcec --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/QuickSharePermissionBottomSheet.kt @@ -0,0 +1,81 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +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 +import com.nextcloud.android.common.ui.share.model.ui.label + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuickSharePermissionBottomSheet( + options: List, + 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) + ) + + options.forEach { option -> + ListItem( + modifier = Modifier.clickable { onOptionSelected(option) }, + headlineContent = { Text(option.label()) }, + 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/bottomsheet/RecipientPermissionBottomSheet.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/RecipientPermissionBottomSheet.kt new file mode 100644 index 00000000..76713f49 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/bottomsheet/RecipientPermissionBottomSheet.kt @@ -0,0 +1,231 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +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.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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.component.ShareSwitch +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.share.Share +import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption +import com.nextcloud.android.common.ui.share.model.ui.effectivePermissionsFor +import com.nextcloud.android.common.ui.share.model.ui.label +import com.nextcloud.android.common.ui.share.model.ui.presetOption +import com.nextcloud.android.common.ui.share.model.ui.recipientPresetOptions +import com.nextcloud.android.common.ui.share.viewmodel.ShareViewModel + +private val SHEET_BOTTOM_PADDING = 32.dp +private val CONTENT_PADDING = 16.dp +private val REMOVE_VERTICAL_PADDING = 14.dp +private val REMOVE_ICON_SIZE = 22.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecipientPermissionBottomSheet( + share: Share, + recipient: Recipient, + permissionPresets: List, + viewModel: ShareViewModel, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val selectedOption = share.effectivePermissionsFor(recipient).presetOption(permissionPresets) + var showCustomPermissions by remember(recipient.value) { + mutableStateOf(selectedOption == PermissionPresetOption.Custom) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(bottom = SHEET_BOTTOM_PADDING) + ) { + Text( + text = stringResource(R.string.share_view_recipient_permission_title, recipient.displayName), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(CONTENT_PADDING) + ) + + share.recipientPresetOptions(permissionPresets).forEach { option -> + PresetOptionItem( + option = option, + isSelected = option == if (showCustomPermissions) PermissionPresetOption.Custom else selectedOption, + isShareDefault = option.presetClass != null && option.presetClass == share.permissionPreset, + onClick = { + val presetClass = option.presetClass + if (presetClass == null) { + showCustomPermissions = true + return@PresetOptionItem + } + + onDismiss() + viewModel.updateRecipientPermissionPreset(share, recipient, presetClass) + } + ) + } + + if (showCustomPermissions) { + HorizontalDivider() + + CustomPermissions( + share = share, + recipient = recipient, + viewModel = viewModel + ) + } + + HorizontalDivider() + + RemoveRecipientItem( + onClick = { + onDismiss() + viewModel.removeRecipient(share.id, recipient.clazz, recipient.value, recipient.instance) + } + ) + } + } +} + +@Composable +private fun RemoveRecipientItem(onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(horizontal = CONTENT_PADDING, vertical = REMOVE_VERTICAL_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(REMOVE_ICON_SIZE) + ) + + Spacer(modifier = Modifier.width(CONTENT_PADDING)) + + Text( + text = stringResource(R.string.share_view_recipient_remove), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + } +} + +@Composable +private fun CustomPermissions(share: Share, recipient: Recipient, viewModel: ShareViewModel) { + val effectivePermissions = share.effectivePermissionsFor(recipient) + + Column(modifier = Modifier.padding(horizontal = CONTENT_PADDING)) { + Text( + text = stringResource(R.string.share_view_recipient_permission_custom_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = CONTENT_PADDING) + ) + + share.permissions.forEach { permission -> + key(permission.clazz) { + val isEnabled = effectivePermissions.first { it.clazz == permission.clazz }.enabled + var checked by remember(permission.clazz, isEnabled) { mutableStateOf(isEnabled) } + + ShareSwitch( + label = permission.displayName, + checked = checked, + onCheckedChange = { value -> + checked = value + viewModel.updateRecipientPermission(share.id, recipient, permission.clazz, value) + }, + enabled = permission.enabled + ) + } + } + + Text( + text = stringResource(R.string.share_view_recipient_permission_cap_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = CONTENT_PADDING) + ) + } +} + +@Composable +private fun PresetOptionItem( + option: PermissionPresetOption, + isSelected: Boolean, + isShareDefault: Boolean, + onClick: () -> Unit +) { + val label = option.label() + + ListItem( + modifier = Modifier.clickable { onClick() }, + headlineContent = { + Text( + text = if (isShareDefault) { + stringResource(R.string.share_view_recipient_permission_default, label) + } else { + label + } + ) + }, + trailingContent = { + if (isSelected) { + 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..bdbeef75 --- /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 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +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)) + } + } + ) +} 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..2310f621 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyEnumField.kt @@ -0,0 +1,73 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +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, isError: Boolean, 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 = isError, + 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/SharePropertyMessage.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyMessage.kt new file mode 100644 index 00000000..c5229ca4 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyMessage.kt @@ -0,0 +1,38 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +package com.nextcloud.android.common.ui.share.component.property + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +private val MESSAGE_START_PADDING = 16.dp +private val MESSAGE_BOTTOM_PADDING = 4.dp + +@Composable +fun SharePropertyMessage(errorMessage: String?, hint: String?) { + val message = errorMessage ?: hint?.takeIf { it.isNotBlank() } ?: return + val color = if (errorMessage != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Text( + text = message, + color = color, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = MESSAGE_START_PADDING, bottom = MESSAGE_BOTTOM_PADDING) + ) +} 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..094179e0 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/SharePropertyView.kt @@ -0,0 +1,146 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming") + +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.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.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +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 + +private const val SINGLE_LINE_COUNT = 1 +private const val MULTILINE_MIN_LINES = 1 +private const val MULTILINE_MAX_LINES = 3 + +@Composable +fun propertyErrorMessage(propertyErrors: Map, clazz: String): String? { + val fallbackError = stringResource(R.string.share_view_update_error_message) + if (!propertyErrors.containsKey(clazz)) return null + return propertyErrors[clazz] ?: fallbackError +} + +@Composable +fun SharePropertyView(property: Property, errorMessage: String?, onValueChange: (String?) -> Unit) { + Column { + SharePropertyField( + property = property, + isError = errorMessage != null, + onValueChange = onValueChange + ) + + SharePropertyMessage(errorMessage = errorMessage, hint = property.hint) + } +} + +@Composable +private fun SharePropertyField(property: Property, isError: Boolean, onValueChange: (String?) -> Unit) { + when (property) { + is PropertyBoolean -> { + var checkedValue by remember(property.clazz) { mutableStateOf(property.isTrue()) } + + ShareSwitch( + label = property.displayName, + checked = checkedValue, + onCheckedChange = { isChecked -> + checkedValue = isChecked + onValueChange(isChecked.toString()) + } + ) + } + + is PropertyString -> ShareTextPropertyField( + property = property, + isError = isError, + onValueChange = onValueChange + ) + + is PropertyPassword -> ShareTextPropertyField( + property = property, + isError = isError, + onValueChange = onValueChange, + visualTransformation = PasswordVisualTransformation() + ) + + is PropertyDate -> ShareDatePicker( + property = property, + isError = isError, + onDateSelected = onValueChange + ) + + is PropertyEnum -> SharePropertyEnumField( + property = property, + isError = isError, + onValueSelected = onValueChange + ) + } +} + +@Composable +private fun ShareTextPropertyField( + property: Property, + isError: Boolean, + onValueChange: (String?) -> Unit, + visualTransformation: VisualTransformation = VisualTransformation.None +) { + val stringProperty = property as? PropertyString + val isMultiline = stringProperty?.isMultiline == true + val maxLength = stringProperty?.maxLength + val clazz = property.clazz + val initialValue = property.value ?: "" + var value by remember(clazz) { mutableStateOf(initialValue) } + var committedValue by remember(clazz) { mutableStateOf(initialValue) } + var wasFocused by remember(clazz) { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = { newValue -> + if (maxLength == null || newValue.length <= maxLength) { + value = newValue + } + }, + label = { Text(property.displayName) }, + visualTransformation = visualTransformation, + isError = isError, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .onFocusChanged { focusState -> + if (wasFocused && !focusState.isFocused && value != committedValue) { + committedValue = value + onValueChange(value) + } + wasFocused = focusState.isFocused + }, + singleLine = !isMultiline, + minLines = if (isMultiline) MULTILINE_MIN_LINES else SINGLE_LINE_COUNT, + maxLines = if (isMultiline) MULTILINE_MAX_LINES else SINGLE_LINE_COUNT + ) +} 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..2992a1c2 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/ShareDatePicker.kt @@ -0,0 +1,183 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +@file:Suppress("FunctionNaming", "UnusedPrivateMember") + +package com.nextcloud.android.common.ui.share.component.property.datepicker + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDefaults +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DatePickerState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +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 androidx.compose.ui.unit.dp +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.component.property.datepicker.util.FutureOnlySelectableDates +import com.nextcloud.android.common.ui.share.component.property.datepicker.util.ShareDateFormatter +import com.nextcloud.android.common.ui.share.model.api.property.PropertyDate +import com.nextcloud.android.common.ui.util.extensions.Strings + +private val TITLE_MIN_WINDOW_HEIGHT = 560.dp +private val TITLE_PADDING = PaddingValues(start = 24.dp, end = 12.dp, top = 16.dp) + +@Composable +fun ShareDatePicker(property: PropertyDate, isError: Boolean, onDateSelected: (String) -> Unit) { + val formatter = remember { ShareDateFormatter() } + var showDatePicker by remember { mutableStateOf(false) } + var dateValue by remember(property.clazz) { mutableStateOf(formatter.formatIsoForDisplay(property.value)) } + + val interactionSource = remember { MutableInteractionSource() } + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + if (interaction is PressInteraction.Release) { + showDatePicker = true + } + } + } + + OutlinedTextField( + value = dateValue, + onValueChange = {}, + readOnly = true, + enabled = true, + isError = isError, + modifier = Modifier.fillMaxWidth(), + interactionSource = interactionSource, + label = { Text(formatter.getDisplayName(property)) }, + trailingIcon = { + val isDateBlank = dateValue.isBlank() + val iconId = if (isDateBlank) { + R.drawable.ic_calendar + } else { + R.drawable.ic_cancel + } + IconButton(onClick = { + if (isDateBlank) { + showDatePicker = true + } else { + dateValue = Strings.EMPTY + onDateSelected(Strings.EMPTY) + } + }) { + Icon( + painter = painterResource(iconId), + contentDescription = null + ) + } + } + ) + + if (showDatePicker) { + DatePickerModal( + formatter = formatter, + onDateSelected = { displayDate, isoDate -> + dateValue = displayDate ?: Strings.EMPTY + onDateSelected(isoDate ?: Strings.EMPTY) + }, + onDismiss = { showDatePicker = false } + ) + } +} + +@Composable +private fun DatePickerModal( + formatter: ShareDateFormatter, + onDateSelected: (displayDate: String?, isoDate: String?) -> Unit, + onDismiss: () -> Unit +) { + val datePickerState = rememberDatePickerState( + selectableDates = remember { FutureOnlySelectableDates() } + ) + val windowHeight = with(LocalDensity.current) { + LocalWindowInfo.current.containerSize.height.toDp() + } + + 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, + modifier = Modifier.verticalScroll(rememberScrollState()), + title = datePickerTitle(datePickerState, windowHeight) + ) + } +} + +private fun datePickerTitle(state: DatePickerState, windowHeight: Dp): (@Composable () -> Unit)? { + if (windowHeight < TITLE_MIN_WINDOW_HEIGHT) return null + + return { + DatePickerDefaults.DatePickerTitle( + displayMode = state.displayMode, + modifier = Modifier.padding(TITLE_PADDING) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ShareDatePickerPreview() { + MaterialTheme { + ShareDatePicker( + property = PropertyDate( + clazz = "expiry", + displayName = "Expiration Date", + priority = 1, + required = false, + advanced = false, + value = "2026-12-31", + minDate = "2026-01-01", + maxDate = "2027-12-31" + ), + isError = false, + onDateSelected = {} + ) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/FutureOnlySelectableDates.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/FutureOnlySelectableDates.kt new file mode 100644 index 00000000..f020608f --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/FutureOnlySelectableDates.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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.SelectableDates +import java.time.LocalDate +import java.time.ZoneOffset + +class FutureOnlySelectableDates : SelectableDates { + private val tomorrow: LocalDate = LocalDate.now(ZoneOffset.UTC).plusDays(1) + private val tomorrowUtcMillis: Long = tomorrow.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + + override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= tomorrowUtcMillis + + override fun isSelectableYear(year: Int): Boolean = year >= tomorrow.year +} 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..b4943899 --- /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 Alper Ozturk + * 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/EnumExtensions.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/EnumExtensions.kt new file mode 100644 index 00000000..2f2bc6ca --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/EnumExtensions.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api + +import kotlinx.serialization.serializer + +inline fun > T.apiValue(): String = serializer().descriptor.getElementName(ordinal) 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..9e5fcca7 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SharingCapabilities.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.capabilities + +import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SharingCapabilities( + @SerialName("api_versions") + val apiVersions: List = emptyList(), + @SerialName("source_types") + val sourceTypes: List = emptyList(), + @SerialName("permission_presets") + val permissionPresets: List = emptyList() +) { + val isUnifiedShareEnabled: Boolean + get() = UNIFIED_SHARE_API_VERSION in apiVersions + + companion object { + const val UNIFIED_SHARE_API_VERSION = "v1" + } +} 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..9480c5a7 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/capabilities/SourceType.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..584e9f02 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/icon/Icon.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..4f763cce --- /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 Alper Ozturk + * 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 + +/** + * [presets] holds the class identifiers of the [PermissionPreset]s this permission belongs to. + */ +@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 priority: Int, + 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..56b95d7c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/permission/PermissionPreset.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 PermissionPreset( + @SerialName("class") + val clazz: String, + @SerialName("display_name") + val displayName: String, + val hint: String? = null +) 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..bdc07b66 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/property/Property.kt @@ -0,0 +1,107 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 advanced: Boolean + abstract val value: String? + + open val isFilledIn: Boolean + get() = !value.isNullOrBlank() +} + +@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 advanced: Boolean, + override val value: String? = null +) : Property() { + fun isTrue(): Boolean = value == "true" + + override val isFilledIn: Boolean + get() = isTrue() +} + +@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 advanced: 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 advanced: 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 advanced: 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 advanced: Boolean, + override val value: String? = null, + @SerialName("min_length") val minLength: Int? = null, + @SerialName("max_length") val maxLength: Int? = null +) : Property() { + companion object { + private const val SINGLE_LINE_MAX_LENGTH = 255 + } + + val isMultiline: Boolean + get() = maxLength == null || maxLength > SINGLE_LINE_MAX_LENGTH +} 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..55771472 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/recipients/Recipient.kt @@ -0,0 +1,40 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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.permission.Permission +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, + val permissions: List = emptyList() +) { + companion object { + const val USER_RECIPIENT_CLASS = """OC\Core\Sharing\Recipient\UserShareRecipientType""" + const val GROUP_RECIPIENT_CLASS = """OC\Core\Sharing\Recipient\GroupShareRecipientType""" + const val TEAM_RECIPIENT_CLASS = """OC\Core\Sharing\Recipient\TeamShareRecipientType""" + const val EMAIL_RECIPIENT_CLASS = """OC\Core\Sharing\Recipient\EmailShareRecipientType""" + const val TOKEN_RECIPIENT_CLASS = """OC\Core\Sharing\Recipient\TokenShareRecipientType""" + } + + fun isSameAs(other: Recipient): Boolean = clazz == other.clazz && value == other.value && instance == other.instance +} 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..3fb56fd7 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/request/ShareRequests.kt @@ -0,0 +1,73 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.request + +import com.nextcloud.android.common.ui.share.model.api.state.ShareState +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class GetShareRequest(val secret: String? = null, val arguments: Map = emptyMap()) + +@Serializable +data class UpdateShareStateRequest( + @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 UpdateShareRecipientPermissionRequest( + val recipientClass: String, + val recipientValue: String, + val recipientInstance: String? = null, + val permissionClass: String, + val enabled: Boolean +) + +@Serializable +data class UpdateSharePermissionPresetRequest(val permissionPresetClass: String) + +@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..921d5bc6 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/secret/Secret.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..b37fcea6 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/Share.kt @@ -0,0 +1,131 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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.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.state.ShareUserStatus +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, + @SerialName("user_status") + val userStatus: ShareUserStatus? = null, + val sources: List, + val recipients: List, + val properties: List, + val permissions: List, + @SerialName("permission_preset") + val permissionPreset: String? = null +) { + companion object { + private const val LABEL_PROPERTY_CLASS = "label" + } + + val basicProperties: List by lazy { + properties.filterNot { it.advanced }.sortedBy { it.priority } + } + + val advancedProperties: List by lazy { + properties.filter { it.advanced }.sortedBy { it.priority } + } + + val isBasicSectionAvailable: Boolean + get() { + return basicProperties.isNotEmpty() + } + + val isAdvancedSectionAvailable: Boolean + get() { + return advancedProperties.isNotEmpty() || customLinkRecipient != null + } + + fun toActiveShare(): ActiveShareState = ActiveShareState.Editing(this) + + fun getClipEntry(internalLink: String, category: ShareCategory): ClipEntry? { + if (category != ShareCategory.Anyone) { + return recipients.firstOrNull()?.let { ClipData.newPlainText(it.displayName, internalLink).toClipEntry() } + } + + return customLinkRecipient?.let { recipient -> + recipient.secret.url?.let { ClipData.newPlainText(recipient.displayName, it).toClipEntry() } + } + } + + fun getHeadline(context: Context, shares: List): String { + if (!belongsAnyoneTab) { + return recipients.firstOrNull()?.displayName ?: "" + } + + return label ?: publicLinkHeadline(context, shares) + } + + private fun publicLinkHeadline(context: Context, shares: List): String { + val publicLinks = shares.filter { it.belongsAnyoneTab } + if (publicLinks.size <= 1) { + return context.getString(R.string.share_view_public_link) + } + + val position = publicLinks.sortedWith(ShareCreationOrder).indexOfFirst { it.id == id } + 1 + return context.getString(R.string.share_view_public_link_numbered, position) + } + + val label: String? + get() = + properties + .firstOrNull { it.clazz.contains(LABEL_PROPERTY_CLASS, ignoreCase = true) } + ?.value + ?.takeIf { it.isNotBlank() } + + val hasMultipleRecipients: Boolean + get() = recipients.size > 1 + + val belongsAnyoneTab: Boolean + get() { + return customLinkRecipient != null + } + + val customLinkRecipient: Recipient? by lazy { + recipients.firstOrNull { it.secret.updatable } + } + + val invitedRecipients: List + get() = recipients.filterNot { it.clazz == Recipient.TOKEN_RECIPIENT_CLASS } + + 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 { + val firstRecipient = sources.firstOrNull()?.displayName ?: "..." + return context.getString(R.string.share_view_bottom_sheet_title, firstRecipient) + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/ShareCreationOrder.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/ShareCreationOrder.kt new file mode 100644 index 00000000..c2091de2 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/share/ShareCreationOrder.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.api.share + +object ShareCreationOrder : Comparator { + override fun compare(first: Share, second: Share): Int { + val firstId = first.id.toBigIntegerOrNull() + val secondId = second.id.toBigIntegerOrNull() + + if (firstId == null || secondId == null) { + return first.id.compareTo(second.id) + } + + return firstId.compareTo(secondId) + } +} 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..165b0977 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/source/Source.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 +) { + companion object { + const val NODE_SOURCE_CLASS = """OCA\Files\Sharing\Source\NodeShareSourceType""" + } +} 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..4efd2333 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/state/ShareState.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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/state/ShareUserStatus.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/state/ShareUserStatus.kt new file mode 100644 index 00000000..7560c742 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/state/ShareUserStatus.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 ShareUserStatus { + @SerialName("pending") + PENDING, + + @SerialName("accepted") + ACCEPTED, + + @SerialName("rejected") + REJECTED +} 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..9384a013 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/api/user/User.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..6cd968be --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ActiveShareState.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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..7522d4c3 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOption.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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.permission.PermissionPreset +import com.nextcloud.android.common.ui.share.model.api.share.Share + +sealed interface PermissionPresetOption { + val presetClass: String? + + data object Custom : PermissionPresetOption { + override val presetClass: String? = null + } + + data class Preset(val preset: PermissionPreset) : PermissionPresetOption { + override val presetClass: String get() = preset.clazz + } + + companion object { + fun optionsFor(share: Share, presets: List): List { + val applicableClasses = share.permissions.flatMap { it.presets }.toSet() + return presets.filter { it.clazz in applicableClasses }.map { Preset(it) } + Custom + } + + fun from(presetClass: String?, presets: List): PermissionPresetOption { + if (presetClass == null) return Custom + return presets.firstOrNull { it.clazz == presetClass }?.let { Preset(it) } ?: Custom + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOptionLabel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOptionLabel.kt new file mode 100644 index 00000000..7f37d4a1 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/PermissionPresetOptionLabel.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.nextcloud.android.common.ui.R + +@Composable +fun PermissionPresetOption.label(): String = when (this) { + PermissionPresetOption.Custom -> stringResource(R.string.share_view_permission_preset_custom) + is PermissionPresetOption.Preset -> preset.displayName +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/RecipientPermissions.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/RecipientPermissions.kt new file mode 100644 index 00000000..de63341a --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/RecipientPermissions.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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.permission.Permission +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.share.Share + +fun Share.effectivePermissionsFor(recipient: Recipient): List { + val overrides = recipient.permissions.associateBy { it.clazz } + return permissions.map { permission -> + val override = overrides[permission.clazz] ?: return@map permission + permission.copy(enabled = permission.enabled && override.enabled) + } +} + +fun Share.grantedPermissions(): List = permissions.filter { it.enabled } + +fun List.presetOption(presets: List): PermissionPresetOption { + val enabled = filter { it.enabled }.map { it.clazz }.toSet() + val preset = + presets.firstOrNull { preset -> + val members = filter { preset.clazz in it.presets }.map { it.clazz } + members.isNotEmpty() && members.size == enabled.size && enabled.containsAll(members) + } + return preset?.let { PermissionPresetOption.Preset(it) } ?: PermissionPresetOption.Custom +} + +fun Share.recipientPresetOptions(presets: List): List { + val selectable = + presets.filter { preset -> + val members = permissions.filter { preset.clazz in it.presets } + members.isNotEmpty() && members.all { it.enabled } + } + return selectable.map { PermissionPresetOption.Preset(it) } + PermissionPresetOption.Custom +} + +fun Share.recipientPermissionChanges(recipient: Recipient, presetClass: String): Map { + val effective = effectivePermissionsFor(recipient).associate { it.clazz to it.enabled } + return grantedPermissions() + .associate { it.clazz to (presetClass in it.presets) } + .filterNot { (clazz, enabled) -> effective[clazz] == enabled } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/RecipientSummary.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/RecipientSummary.kt new file mode 100644 index 00000000..e4883bee --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/RecipientSummary.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.ui + +import androidx.annotation.PluralsRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import com.nextcloud.android.common.ui.R +import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient + +private val SUMMARY_PLURALS = + mapOf( + Recipient.USER_RECIPIENT_CLASS to R.plurals.share_view_recipient_summary_people, + Recipient.GROUP_RECIPIENT_CLASS to R.plurals.share_view_recipient_summary_groups, + Recipient.TEAM_RECIPIENT_CLASS to R.plurals.share_view_recipient_summary_teams, + Recipient.EMAIL_RECIPIENT_CLASS to R.plurals.share_view_recipient_summary_emails, + Recipient.TOKEN_RECIPIENT_CLASS to R.plurals.share_view_recipient_summary_links + ) + +@Composable +fun recipientSummary(recipients: List): String { + val counts = recipients.groupingBy { it.clazz }.eachCount() + val parts = + SUMMARY_PLURALS.mapNotNull { (clazz, pluralId) -> + counts[clazz]?.let { count -> quantityLabel(pluralId, count) } + } + val unknownCount = recipients.count { it.clazz !in SUMMARY_PLURALS } + val unknownPart = quantityLabel(R.plurals.share_view_recipient_summary_others, unknownCount) + + return (parts + listOfNotNull(unknownPart)).joinToString( + separator = stringResource(R.string.share_view_recipient_summary_separator) + ) +} + +@Composable +private fun quantityLabel(@PluralsRes pluralId: Int, count: Int): String? { + if (count == 0) return null + return pluralStringResource(pluralId, count, count) +} 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..115b2bbf --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareCategory.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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/ShareEditorEntry.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareEditorEntry.kt new file mode 100644 index 00000000..f6584467 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareEditorEntry.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.ui + +enum class ShareEditorEntry { + EDIT, + CUSTOMIZE_PERMISSION +} 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..36b81043 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareItemType.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 + +private val OUTER_CORNER_RADIUS = 12.dp +private val INNER_CORNER_RADIUS = 4.dp + +enum class ShareItemType { + Single, + Top, + Mid, + Bottom; + + @Composable + fun getShape(): RoundedCornerShape = when (this) { + Single -> { + RoundedCornerShape(OUTER_CORNER_RADIUS) + } + + Top -> { + RoundedCornerShape( + OUTER_CORNER_RADIUS, + OUTER_CORNER_RADIUS, + INNER_CORNER_RADIUS, + INNER_CORNER_RADIUS + ) + } + + Mid -> { + RoundedCornerShape(INNER_CORNER_RADIUS) + } + + Bottom -> { + RoundedCornerShape( + INNER_CORNER_RADIUS, + INNER_CORNER_RADIUS, + OUTER_CORNER_RADIUS, + OUTER_CORNER_RADIUS + ) + } + } + + companion object { + fun type(index: Int, lastIndex: Int): ShareItemType = when { + lastIndex == 0 -> Single + index == 0 -> Top + index == lastIndex -> Bottom + else -> Mid + } + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareListItemActions.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareListItemActions.kt new file mode 100644 index 00000000..cc622c73 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareListItemActions.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 + +data class ShareListItemActions( + val onSelectShare: (Share) -> Unit, + val onToggleExpanded: (Share) -> Unit, + val onShowOverlay: (ShareOverlay) -> Unit +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareListItemState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareListItemState.kt new file mode 100644 index 00000000..f80afd20 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareListItemState.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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 + +data class ShareListItemState(val share: Share, val title: String, val type: ShareItemType, val isExpanded: Boolean) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareOverlay.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareOverlay.kt new file mode 100644 index 00000000..f6211b2d --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareOverlay.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.ui + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver + +sealed class ShareOverlay { + abstract val shareId: String? + + data object None : ShareOverlay() { + override val shareId: String? = null + } + + data class QuickShare(override val shareId: String) : ShareOverlay() + + data class RecipientPermission( + override val shareId: String, + val recipientClass: String, + val recipientValue: String, + val recipientInstance: String? + ) : ShareOverlay() + + companion object { + private const val QUICK_SHARE = "quick_share" + private const val RECIPIENT_PERMISSION = "recipient_permission" + private const val RECIPIENT_CLASS_INDEX = 2 + private const val RECIPIENT_VALUE_INDEX = 3 + private const val RECIPIENT_INSTANCE_INDEX = 4 + + val Saver: Saver = + listSaver( + save = { overlay -> + when (overlay) { + None -> { + emptyList() + } + + is QuickShare -> { + listOf(QUICK_SHARE, overlay.shareId) + } + + is RecipientPermission -> { + listOf( + RECIPIENT_PERMISSION, + overlay.shareId, + overlay.recipientClass, + overlay.recipientValue, + overlay.recipientInstance.orEmpty() + ) + } + } + }, + restore = { saved -> + val shareId = saved.getOrNull(1) + when (saved.firstOrNull()) { + QUICK_SHARE -> { + shareId?.let(::QuickShare) + } + + RECIPIENT_PERMISSION -> { + shareId?.let { + RecipientPermission( + shareId = it, + recipientClass = saved[RECIPIENT_CLASS_INDEX], + recipientValue = saved[RECIPIENT_VALUE_INDEX], + recipientInstance = saved[RECIPIENT_INSTANCE_INDEX].takeIf(String::isNotEmpty) + ) + } + } + + else -> { + None + } + } ?: None + } + ) + } +} 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..0bae4057 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/model/ui/ShareScreenState.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.model.ui + +import com.nextcloud.android.common.ui.share.model.api.share.Share + +sealed class ShareScreenState { + data object Loading : ShareScreenState() + + data object Empty : ShareScreenState() + + data object Error : ShareScreenState() + + data class Loaded(val shares: List) : ShareScreenState() +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt new file mode 100644 index 00000000..929591e3 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/MockShareRepository.kt @@ -0,0 +1,545 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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.capabilities.SharingCapabilities +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.UpdateShareRecipientPermissionRequest +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 + +@Suppress("TooManyFunctions", "LongParameterList") +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", + priority = 10, + presets = listOf("view", "edit"), + enabled = true + ), + Permission( + clazz = "update", + displayName = "Update", + priority = 20, + presets = listOf("edit"), + enabled = false + ), + Permission( + clazz = "delete", + displayName = "Delete", + priority = 30, + presets = listOf("edit"), + enabled = false + ) + ) + + private val mockProperties = listOf( + PropertyString( + clazz = "note", + displayName = "Note", + priority = 10, + required = false, + advanced = false, + value = "Design review – please check latest changes" + ), + PropertyPassword( + clazz = "password", + displayName = "Password", + priority = 20, + required = false, + advanced = false, + value = null + ), + PropertyDate( + clazz = "expiration_date", + displayName = "Expiration date", + priority = 30, + required = false, + advanced = true, + value = null, + minDate = "2026-01-01", + maxDate = "2027-01-01" + ), + PropertyBoolean( + clazz = "hide_download", + displayName = "Hide download", + priority = 40, + required = false, + advanced = true, + 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( + filterSourceTypeValue: String, + limit: Int?, + filterSourceTypeClass: String?, + filterState: ShareState?, + lastShareID: String? + ): NetworkResult> { + val result = mockShares.filter { share -> + (filterState == null || share.shareState == filterState) && + share.sources.any { + it.value == filterSourceTypeValue && + (filterSourceTypeClass == null || it.clazz == filterSourceTypeClass) + } + } + + return NetworkResult.Success(result) + } + + 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 updateShareRecipientPermission( + id: String, + request: UpdateShareRecipientPermissionRequest + ): NetworkResult { + val index = mockShares.indexOfFirst { it.id == id } + val current = + if (index >= 0) mockShares[index] else buildShare(id = id, sources = emptyList(), recipients = emptyList()) + val updatedRecipients = current.recipients.map { recipient -> + val isTarget = recipient.clazz == request.recipientClass && + recipient.value == request.recipientValue && + recipient.instance == request.recipientInstance + if (!isTarget) return@map recipient + + val override = current.permissions + .firstOrNull { it.clazz == request.permissionClass } + ?.copy(enabled = request.enabled) + ?: return@map recipient + + recipient.copy( + permissions = recipient.permissions.filterNot { it.clazz == override.clazz } + override + ) + } + val updated = current.copy( + recipients = updatedRecipients, + 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.permissionPresetClass, + lastUpdated = System.currentTimeMillis() + ) + if (index >= 0) mockShares[index] = updated + return NetworkResult.Success(updated) + } + + override suspend fun fetchSharingCapabilities(): NetworkResult = NetworkResult.Success( + SharingCapabilities( + permissionPresets = listOf( + PermissionPreset(clazz = "view", displayName = "Can view"), + PermissionPreset(clazz = "edit", displayName = "Can edit") + ) + ) + ) +} 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..532d8509 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRemoteRepository.kt @@ -0,0 +1,221 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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.apiValue +import com.nextcloud.android.common.ui.share.model.api.capabilities.SharingCapabilities +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.UpdateShareRecipientPermissionRequest +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 kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URLEncoder + +private fun String.urlEncoded(): String = URLEncoder.encode(this, Charsets.UTF_8.name()) + +@Suppress("TooManyFunctions") +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" + private const val CAPABILITIES_ENDPOINT = "/ocs/v2.php/cloud/capabilities" + private const val NODE_CAPABILITIES = "capabilities" + private const val NODE_SHARING = "sharing" + } + + private fun decodeShare(body: String): Share = json.decodeFromString>(body).ocs.data + + override suspend fun fetchRecipients( + recipientTypeClasses: List?, + query: String, + limit: Int, + offset: Int + ): NetworkResult> { + val queryParams = + buildString { + append("?query=${query.urlEncoded()}&limit=$limit&offset=$offset") + recipientTypeClasses?.forEach { append("&recipientTypeClasses[]=${it.urlEncoded()}") } + } + 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() + ) { decodeShare(it) } + + 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) + ) { decodeShare(it) } + + override suspend fun deleteShare(id: String): NetworkResult = client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id", + method = HttpMethod.DELETE + ) { } + + override suspend fun fetchShares( + filterSourceTypeValue: String, + limit: Int?, + filterSourceTypeClass: String?, + filterState: ShareState?, + lastShareID: String? + ): NetworkResult> { + val queryParams = + buildString { + append("?filterSourceTypeValue=${filterSourceTypeValue.urlEncoded()}") + limit?.let { append("&limit=$it") } + filterSourceTypeClass?.let { append("&filterSourceTypeClass=${it.urlEncoded()}") } + filterState?.let { append("&filterState=${it.apiValue().urlEncoded()}") } + lastShareID?.let { append("&lastShareID=${it.urlEncoded()}") } + } + 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) + ) { decodeShare(it) } + + 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) + ) { decodeShare(it) } + + override suspend fun removeShareSource(id: String, clazz: String, value: String): NetworkResult = + client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/source?class=${clazz.urlEncoded()}&value=${value.urlEncoded()}", + method = HttpMethod.DELETE + ) { decodeShare(it) } + + 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) + ) { decodeShare(it) } + + override suspend fun removeShareRecipient( + id: String, + clazz: String, + value: String, + instance: String? + ): NetworkResult { + val queryParams = + buildString { + append("?class=${clazz.urlEncoded()}&value=${value.urlEncoded()}") + instance?.let { append("&instance=${it.urlEncoded()}") } + } + return client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/recipient$queryParams", + method = HttpMethod.DELETE + ) { decodeShare(it) } + } + + 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) + ) { decodeShare(it) } + + 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) + ) { decodeShare(it) } + + override suspend fun updateShareRecipientPermission( + id: String, + request: UpdateShareRecipientPermissionRequest + ): NetworkResult = client.executeRequest( + endpoint = "$SHARE_ENDPOINT/$id/recipient/permission", + method = HttpMethod.PUT, + body = json.encodeToString(request).toRequestBody(JSON_CONTENT_TYPE) + ) { decodeShare(it) } + + 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) + ) { decodeShare(it) } + + 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) + ) { decodeShare(it) } + + override suspend fun generateSecret(): NetworkResult = client.executeRequest( + endpoint = SECRET_ENDPOINT, + method = HttpMethod.GET + ) { body -> + json.decodeFromString>(body).ocs.data + } + + override suspend fun fetchSharingCapabilities(): NetworkResult = client.executeRequest( + endpoint = CAPABILITIES_ENDPOINT, + method = HttpMethod.GET + ) { body -> + val sharing = + json + .decodeFromString>(body) + .ocs.data[NODE_CAPABILITIES] + ?.jsonObject + ?.get(NODE_SHARING) + sharing?.let { json.decodeFromJsonElement(it) } ?: SharingCapabilities() + } +} 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..ed3b8d14 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/repository/ShareRepository.kt @@ -0,0 +1,82 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * 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.capabilities.SharingCapabilities +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.UpdateShareRecipientPermissionRequest +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 + +@Suppress("TooManyFunctions") +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( + filterSourceTypeValue: String, + limit: Int? = null, + filterSourceTypeClass: String? = null, + filterState: ShareState? = null, + lastShareID: String? = null + ): 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 updateShareRecipientPermission( + id: String, + request: UpdateShareRecipientPermissionRequest + ): NetworkResult + + suspend fun updateSharePermissionPreset( + id: String, + request: UpdateSharePermissionPresetRequest + ): NetworkResult + + suspend fun updateShareRecipientSecret(id: String, request: UpdateShareRecipientSecretRequest): NetworkResult + + suspend fun generateSecret(): NetworkResult + + suspend fun fetchSharingCapabilities(): NetworkResult +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareSavedState.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareSavedState.kt new file mode 100644 index 00000000..b2ded119 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareSavedState.kt @@ -0,0 +1,43 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.nextcloud.android.common.ui.share.model.ui.ShareEditorEntry +import kotlinx.coroutines.flow.StateFlow + +class ShareSavedState(private val handle: SavedStateHandle) { + companion object { + private const val KEY_ACTIVE_SHARE_ID = "active_share_id" + private const val KEY_EDITOR_ENTRY = "editor_entry" + private const val KEY_SEARCH_QUERY = "search_query" + } + + var activeShareId: String? + get() = handle[KEY_ACTIVE_SHARE_ID] + set(value) { + handle[KEY_ACTIVE_SHARE_ID] = value + } + + var editorEntry: ShareEditorEntry + get() = + handle + .get(KEY_EDITOR_ENTRY) + ?.let { name -> ShareEditorEntry.entries.firstOrNull { it.name == name } } + ?: ShareEditorEntry.EDIT + set(value) { + handle[KEY_EDITOR_ENTRY] = value.name + } + + val searchQuery: StateFlow = handle.getStateFlow(KEY_SEARCH_QUERY, "") + + fun updateSearchQuery(query: String) { + handle[KEY_SEARCH_QUERY] = query + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt new file mode 100644 index 00000000..6a6b4c89 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModel.kt @@ -0,0 +1,566 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.viewmodel + +import androidx.lifecycle.SavedStateHandle +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.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.UpdateShareRecipientPermissionRequest +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.share.ShareCreationOrder +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.ui.ActiveShareState +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.ShareScreenState +import com.nextcloud.android.common.ui.share.model.ui.recipientPermissionChanges +import com.nextcloud.android.common.ui.share.repository.ShareRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@Suppress("TooManyFunctions") +class ShareViewModel( + private val repository: ShareRepository, + private val sourceId: String, + savedStateHandle: SavedStateHandle +) : ViewModel() { + companion object { + private const val SEARCH_DEBOUNCE_DELAY = 300L + private const val SEARCH_SUBSCRIPTION_TIMEOUT = 5_000L + private const val RECIPIENT_SEARCH_LIMIT = 10 + private const val RECIPIENT_SEARCH_OFFSET = 0 + private val PENDING_MUTATION_JOIN_TIMEOUT = 15.seconds + } + + private val savedState = ShareSavedState(savedStateHandle) + + private val _state = MutableStateFlow(ShareScreenState.Loading) + val state: StateFlow = _state.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + private val _isPreparingLink = MutableStateFlow(false) + val isPreparingLink: StateFlow = _isPreparingLink.asStateFlow() + + private val _isCreatingDraft = MutableStateFlow(false) + val isCreatingDraft: StateFlow = _isCreatingDraft.asStateFlow() + + private val _activeShare = MutableStateFlow(ActiveShareState.None) + val activeShare: StateFlow = _activeShare.asStateFlow() + + private val _editorEntry = MutableStateFlow(savedState.editorEntry) + val editorEntry: StateFlow = _editorEntry.asStateFlow() + + private val _permissionPresets = MutableStateFlow>(emptyList()) + val permissionPresets: StateFlow> = _permissionPresets.asStateFlow() + + private val _errorMessageId = MutableStateFlow(null) + val errorMessageId: StateFlow = _errorMessageId.asStateFlow() + + private val _propertyErrors = MutableStateFlow>(emptyMap()) + val propertyErrors: StateFlow> = _propertyErrors.asStateFlow() + + private val _pendingProperties = MutableStateFlow>(emptySet()) + val pendingProperties: StateFlow> = _pendingProperties.asStateFlow() + + private val propertyUpdateJobs = mutableMapOf() + private val pendingUpdateJobs = mutableSetOf() + + val searchQuery: StateFlow = savedState.searchQuery + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val recipientSearchResults: StateFlow> = + searchQuery + .debounce(SEARCH_DEBOUNCE_DELAY.milliseconds) + .distinctUntilChanged() + .filter { it.isNotBlank() } + .mapLatest { query -> searchRecipients(query) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SEARCH_SUBSCRIPTION_TIMEOUT), emptyList()) + + private var secretUpdateJob: Job? = null + + init { + loadInitialData() + } + + override fun onCleared() { + val draft = _activeShare.value.shareOrNull?.takeIf { it.shareState == ShareState.DRAFT } ?: return + CoroutineScope(SupervisorJob()).launch { repository.deleteShare(draft.id) } + } + + private fun loadInitialData() { + viewModelScope.launch { + launch { loadSharingCapabilities() } + launch { restoreActiveShare() } + loadShares() + } + } + + private suspend fun loadSharingCapabilities() { + val result = repository.fetchSharingCapabilities() + if (result is NetworkResult.Success) { + _permissionPresets.update { + result.data.permissionPresets + } + } + } + + private suspend fun restoreActiveShare() { + if (_activeShare.value != ActiveShareState.None) return + val restored = savedState.activeShareId?.let { fetchRestorableShare(it) } ?: return + + updateActiveShare(ActiveShareState.Editing(restored)) + } + + private suspend fun fetchRestorableShare(id: String): Share? { + val result = repository.fetchShare(id) + if (result !is NetworkResult.Success) return null + return result.data.takeIf { savedState.activeShareId == id } + } + + // region search query + fun onSearchQueryChanged(query: String) { + savedState.updateSearchQuery(query) + } + + private suspend fun searchRecipients(query: String): List { + val result = repository.fetchRecipients(null, query, RECIPIENT_SEARCH_LIMIT, RECIPIENT_SEARCH_OFFSET) + return (result as? NetworkResult.Success)?.data ?: emptyList() + } + // endregion + + // region shares list + fun refreshShares() { + if (!_isRefreshing.compareAndSet(expect = false, update = true)) return + + viewModelScope.launch { + try { + loadShares() + } finally { + _isRefreshing.value = false + } + } + } + + private suspend fun loadShares() { + _errorMessageId.update { null } + val result = fetchShares(ShareState.ACTIVE) + val fetched = result.dataOrElse { _errorMessageId.update { R.string.share_view_fetch_error_message } } + + if (fetched == null) { + _state.update { it as? ShareScreenState.Loaded ?: ShareScreenState.Error } + return + } + + publishShares(fetched) + } + + private suspend fun fetchShares(filterState: ShareState): NetworkResult> = repository.fetchShares( + filterSourceTypeValue = sourceId, + filterSourceTypeClass = Source.NODE_SOURCE_CLASS, + filterState = filterState + ) + // endregion + + // region create + fun createDraftShare() { + if (!_isCreatingDraft.compareAndSet(expect = false, update = true)) return + + viewModelScope.launch { + try { + _errorMessageId.update { null } + + val result = repository.createDraftShare() + val draft = result.dataOrElse { + _errorMessageId.update { R.string.share_view_create_error_message } + } ?: return@launch + + updateEditorEntry(ShareEditorEntry.EDIT) + applySource(draft.id, sourceId) + } finally { + _isCreatingDraft.value = false + } + } + } + // endregion + + // region state + fun updateState(id: String, shareState: ShareState) { + viewModelScope.launch { + val updated = applyState(id, shareState) ?: return@launch + + if (shareState == ShareState.ACTIVE) { + updateActiveShare(ActiveShareState.None) + } else { + refreshActiveShare(updated.toActiveShare()) + } + replaceInList(updated) + } + } + + fun prepareLinkForCopy(id: String) { + if (_isPreparingLink.value) return + + viewModelScope.launch { + _isPreparingLink.update { true } + + try { + val updated = applyState(id, ShareState.ACTIVE) + if (updated != null) { + refreshActiveShare(ActiveShareState.Activating(updated)) + replaceInList(updated) + } + } finally { + _isPreparingLink.update { false } + } + } + } + + fun onLinkCopied() { + val activating = _activeShare.value as? ActiveShareState.Activating ?: return + updateActiveShare(ActiveShareState.Editing(activating.share)) + } + + private suspend fun applyState(id: String, shareState: ShareState): Share? { + val result = repository.updateShareState(id, UpdateShareStateRequest(shareState)) + return result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + } + // endregion + + // region sources + private suspend fun applySource(id: String, value: String) { + val result = repository.addShareSource(id, AddSourceRequest(Source.NODE_SOURCE_CLASS, value)) + val updated = + result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return + updateActiveShare(updated.toActiveShare()) + } + // endregion + + // region recipients + fun addRecipient(id: String, clazz: String, value: String, instance: String? = null) { + viewModelScope.launch { + val result = repository.addShareRecipient(id, AddRecipientRequest(clazz, value, instance)) + val updated = + result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + refreshActiveShare(updated.toActiveShare()) + }.trackPendingUpdateJobs() + } + + 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 { + val result = repository.removeShareRecipient(id, clazz, value, instance) + val updated = + result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + refreshActiveShare(updated.toActiveShare()) + replaceInList(updated) + } + } + + fun updateRecipientSecret(shareId: String, recipient: Recipient, secret: String) { + secretUpdateJob?.cancel() + secretUpdateJob = + viewModelScope.launch { + 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 + refreshActiveShare(updated.toActiveShare()) + }.trackPendingUpdateJobs() + } + + suspend fun generateSecret(): String? { + _errorMessageId.update { null } + val result = repository.generateSecret() + return 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() + _pendingProperties.update { it + clazz } + if (value.isNullOrEmpty()) { + _propertyErrors.update { it - clazz } + } + propertyUpdateJobs[clazz] = + viewModelScope.launch { + when (val result = repository.updateShareProperty(shareId, UpdateSharePropertyRequest(clazz, value))) { + is NetworkResult.Success -> { + _propertyErrors.update { it - clazz } + refreshActiveShare(result.data.toActiveShare()) + } + + is NetworkResult.ServerError -> { + if (!value.isNullOrEmpty()) { + _propertyErrors.update { it + (clazz to result.response.ocs.data) } + } + } + + is NetworkResult.NetworkException -> { + if (!value.isNullOrEmpty()) { + _propertyErrors.update { it + (clazz to null) } + } + } + } + _pendingProperties.update { it - clazz } + }.trackPendingUpdateJobs() + } + // endregion + + // region permissions + fun updateRecipientPermission(shareId: String, recipient: Recipient, clazz: String, enabled: Boolean) { + viewModelScope.launch { + applyRecipientPermissions(shareId, recipient, mapOf(clazz to enabled)) + } + } + + fun updateRecipientPermissionPreset(share: Share, recipient: Recipient, presetClass: String) { + val changes = share.recipientPermissionChanges(recipient, presetClass) + if (changes.isEmpty()) return + + viewModelScope.launch { + applyRecipientPermissions(share.id, recipient, changes) + } + } + + private suspend fun applyRecipientPermissions( + shareId: String, + recipient: Recipient, + changes: Map + ) { + var applied: Share? = null + var hasFailed = false + + changes.forEach { (clazz, enabled) -> + val updated = requestRecipientPermission(shareId, recipient, clazz, enabled) + if (updated == null) hasFailed = true else applied = updated + } + + val resolved = if (hasFailed) fetchShareOrNull(shareId) else applied + resolved?.let { + refreshActiveShare(it.toActiveShare()) + replaceInList(it) + } + } + + private suspend fun requestRecipientPermission( + shareId: String, + recipient: Recipient, + clazz: String, + enabled: Boolean + ): Share? { + val request = + UpdateShareRecipientPermissionRequest( + recipientClass = recipient.clazz, + recipientValue = recipient.value, + recipientInstance = recipient.instance, + permissionClass = clazz, + enabled = enabled + ) + val result = repository.updateShareRecipientPermission(shareId, request) + return result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + } + + private suspend fun fetchShareOrNull(shareId: String): Share? = + (repository.fetchShare(shareId) as? NetworkResult.Success)?.data + + fun updatePermission(id: String, clazz: String, enabled: Boolean) { + viewModelScope.launch { + val result = repository.updateSharePermission(id, UpdateSharePermissionRequest(clazz, enabled)) + val updated = + result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + refreshActiveShare(updated.toActiveShare()) + }.trackPendingUpdateJobs() + } + + fun updatePermissionPreset(id: String, presetClass: String, updateActiveShare: Boolean) { + viewModelScope.launch { + val result = repository.updateSharePermissionPreset(id, UpdateSharePermissionPresetRequest(presetClass)) + val updated = + result.dataOrElse { _errorMessageId.update { R.string.share_view_update_error_message } } + ?: return@launch + if (updateActiveShare) { + refreshActiveShare(updated.toActiveShare()) + } + replaceInList(updated) + } + } + // endregion + + // region delete + fun deleteShare(id: String) { + viewModelScope.launch { + val result = repository.deleteShare(id) + result.dataOrElse { _errorMessageId.update { R.string.share_view_delete_error_message } } ?: return@launch + + _state.update { state -> + val shares = ((state as? ShareScreenState.Loaded)?.shares ?: emptyList()).filterNot { it.id == id } + if (shares.isEmpty()) ShareScreenState.Empty else ShareScreenState.Loaded(shares) + } + + val editingShare = _activeShare.value + if (editingShare is ActiveShareState.Editing && editingShare.share.id == id) { + updateActiveShare(ActiveShareState.None) + } + } + } + // endregion + + // region ui helpers + fun updateErrorMessage(value: Int?) { + _errorMessageId.update { value } + } + + fun dismissActiveShare(id: String) { + val active = _activeShare.value.shareOrNull ?: return + if (active.id != id) return + + if (active.shareState == ShareState.DRAFT) { + deleteShare(id) + } else if (active.shareState == ShareState.ACTIVE) { + refreshSharesAfterPendingUpdates() + } + + setActiveShare(null) + } + + private fun refreshSharesAfterPendingUpdates() { + viewModelScope.launch { + withTimeoutOrNull(PENDING_MUTATION_JOIN_TIMEOUT) { + pendingUpdateJobs.toList().joinAll() + } + refreshShares() + } + } + + fun setActiveShare(value: Share?, entry: ShareEditorEntry = ShareEditorEntry.EDIT) { + _propertyErrors.update { emptyMap() } + _pendingProperties.update { emptySet() } + updateEditorEntry(entry) + updateActiveShare(value?.toActiveShare() ?: ActiveShareState.None) + } + // endregion + + // region private + private fun updateEditorEntry(entry: ShareEditorEntry) { + _editorEntry.value = entry + savedState.editorEntry = entry + } + + private fun updateActiveShare(value: ActiveShareState) { + _activeShare.value = value + savedState.activeShareId = value.shareOrNull?.id + } + + private fun refreshActiveShare(value: ActiveShareState) { + val id = value.shareOrNull?.id ?: return + if (_activeShare.value.shareOrNull?.id != id) return + updateActiveShare(value) + } + + private fun Job.trackPendingUpdateJobs(): Job = also { job -> + pendingUpdateJobs += job + job.invokeOnCompletion { pendingUpdateJobs -= job } + } + + private fun replaceInList(updated: Share) { + _state.update { state -> + val current = (state as? ShareScreenState.Loaded)?.shares ?: emptyList() + val index = current.indexOfFirst { it.id == updated.id } + val shares = + if (index >= 0) { + current.toMutableList().apply { this[index] = updated } + } else { + current + updated + } + ShareScreenState.Loaded(shares.sortedWith(ShareCreationOrder)) + } + } + + private fun publishShares(shares: List) { + _state.update { + if (shares.isEmpty()) { + ShareScreenState.Empty + } else { + ShareScreenState.Loaded(shares.sortedWith(ShareCreationOrder)) + } + } + } + // endregion +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModelFactory.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModelFactory.kt new file mode 100644 index 00000000..7a74855c --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/viewmodel/ShareViewModelFactory.kt @@ -0,0 +1,29 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras +import com.nextcloud.android.common.ui.share.repository.ShareRepository + +class ShareViewModelFactory(private val sourceId: String, private val repositoryProvider: () -> ShareRepository) : + ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): T { + require(modelClass.isAssignableFrom(ShareViewModel::class.java)) { + "ShareViewModelFactory cannot create ${modelClass.name}" + } + + val savedStateHandle: SavedStateHandle = extras.createSavedStateHandle() + return ShareViewModel(repositoryProvider(), sourceId, savedStateHandle) as T + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/theme/MaterialSchemes.kt b/ui/src/main/java/com/nextcloud/android/common/ui/theme/MaterialSchemes.kt index b9a3af7a..32c626c0 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/theme/MaterialSchemes.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/theme/MaterialSchemes.kt @@ -25,17 +25,13 @@ interface MaterialSchemes { companion object { fun fromServerTheme(theme: ServerTheme): MaterialSchemes = MaterialSchemesImpl(theme) - fun fromColor( - @ColorInt color: Int - ): MaterialSchemes = MaterialSchemesImpl(color) + fun fromColor(@ColorInt color: Int): MaterialSchemes = MaterialSchemesImpl(color) /** * Creates a [MaterialSchemes] where the primary color is preserved as-is. This is useful for views where * we want it for branding purposes, especially as a background color. The resulting dark and light * schemes will be the same, as they are derived from the primary color and not the device theme. */ - fun withPrimaryAsBackground( - @ColorInt primary: Int - ): MaterialSchemes = MaterialSchemesImpl(primary, true) + fun withPrimaryAsBackground(@ColorInt primary: Int): MaterialSchemes = MaterialSchemesImpl(primary, true) } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/theme/ViewThemeUtilsBase.kt b/ui/src/main/java/com/nextcloud/android/common/ui/theme/ViewThemeUtilsBase.kt index 7a0c9de5..d8a8b975 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/theme/ViewThemeUtilsBase.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/theme/ViewThemeUtilsBase.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2024-2026 Alper Ozturk * SPDX-FileCopyrightText: 2022-2023 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2022-2023 Andy Scherzinger * SPDX-FileCopyrightText: 2022 Álvaro Brey @@ -15,9 +16,7 @@ import com.nextcloud.android.common.ui.util.PlatformThemeUtil import com.nextcloud.android.common.ui.util.extensions.toColorScheme import dynamiccolor.DynamicScheme -open class ViewThemeUtilsBase( - private val schemes: MaterialSchemes -) { +open class ViewThemeUtilsBase(private val schemes: MaterialSchemes) { /** * Scheme for painting elements */ @@ -34,15 +33,9 @@ open class ViewThemeUtilsBase( else -> schemes.lightScheme } - protected fun withScheme( - view: View, - block: (DynamicScheme) -> R - ): R = block(getSchemeInternal(view.context)) + protected fun withScheme(view: View, block: (DynamicScheme) -> R): R = block(getSchemeInternal(view.context)) - protected fun withScheme( - context: Context, - block: (DynamicScheme) -> R - ): R = block(getSchemeInternal(context)) + protected fun withScheme(context: Context, block: (DynamicScheme) -> R): R = block(getSchemeInternal(context)) protected fun withSchemeDark(block: (DynamicScheme) -> R): R = block(schemes.darkScheme) } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/AndroidViewThemeUtils.kt b/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/AndroidViewThemeUtils.kt index 5a611067..3a0096d6 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/AndroidViewThemeUtils.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/AndroidViewThemeUtils.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022-2023 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2022-2023 Andy Scherzinger * SPDX-FileCopyrightText: 2023 Stefan Niedermann @@ -62,649 +63,571 @@ import javax.inject.Inject */ @Suppress("TooManyFunctions") class AndroidViewThemeUtils - @Inject - constructor( - schemes: MaterialSchemes, - private val colorUtil: ColorUtil - ) : ViewThemeUtilsBase(schemes) { - private val dynamicColor = MaterialDynamicColors() - - fun colorBottomNavigationView(bottomNavigationView: BottomNavigationView) { - withScheme(bottomNavigationView) { scheme -> - bottomNavigationView.setBackgroundColor(dynamicColor.surface().getArgb(scheme)) - - bottomNavigationView.itemIconTintList = - buildColorStateList( - android.R.attr.state_checked to dynamicColor.onSecondaryContainer().getArgb(scheme), - -android.R.attr.state_checked to dynamicColor.onSurfaceVariant().getArgb(scheme) - ) +@Inject +constructor(schemes: MaterialSchemes, private val colorUtil: ColorUtil) : + ViewThemeUtilsBase(schemes) { + private val dynamicColor = MaterialDynamicColors() + + fun colorBottomNavigationView(bottomNavigationView: BottomNavigationView) { + withScheme(bottomNavigationView) { scheme -> + bottomNavigationView.setBackgroundColor(dynamicColor.surface().getArgb(scheme)) + + bottomNavigationView.itemIconTintList = + buildColorStateList( + android.R.attr.state_checked to dynamicColor.onSecondaryContainer().getArgb(scheme), + -android.R.attr.state_checked to dynamicColor.onSurfaceVariant().getArgb(scheme) + ) - bottomNavigationView.itemTextColor = - buildColorStateList( - android.R.attr.state_checked to dynamicColor.onSurface().getArgb(scheme), - -android.R.attr.state_checked to dynamicColor.onSurfaceVariant().getArgb(scheme) - ) + bottomNavigationView.itemTextColor = + buildColorStateList( + android.R.attr.state_checked to dynamicColor.onSurface().getArgb(scheme), + -android.R.attr.state_checked to dynamicColor.onSurfaceVariant().getArgb(scheme) + ) - bottomNavigationView.itemActiveIndicatorColor = - ColorStateList - .valueOf(dynamicColor.secondaryContainer().getArgb(scheme)) - } + bottomNavigationView.itemActiveIndicatorColor = + ColorStateList + .valueOf(dynamicColor.secondaryContainer().getArgb(scheme)) } + } - @JvmOverloads - fun colorViewBackground( - view: View, - colorRole: ColorRole = ColorRole.SURFACE - ) { - withScheme(view) { scheme -> - view.setBackgroundColor(colorRole.select(scheme)) - } + @JvmOverloads + fun colorViewBackground(view: View, colorRole: ColorRole = ColorRole.SURFACE) { + withScheme(view) { scheme -> + view.setBackgroundColor(colorRole.select(scheme)) } + } - @JvmOverloads - fun colorNavigationView( - navigationView: NavigationView, - colorIcons: Boolean = true - ) { - withScheme(navigationView) { scheme -> - if (navigationView.itemBackground != null) { - navigationView.itemBackground!!.setTintList( - buildColorStateList( - android.R.attr.state_checked to dynamicColor.secondaryContainer().getArgb(scheme), - -android.R.attr.state_checked to Color.TRANSPARENT - ) - ) - } - navigationView.background.setTintList(ColorStateList.valueOf(dynamicColor.surface().getArgb(scheme))) - - val colorStateList = + @JvmOverloads + fun colorNavigationView(navigationView: NavigationView, colorIcons: Boolean = true) { + withScheme(navigationView) { scheme -> + if (navigationView.itemBackground != null) { + navigationView.itemBackground!!.setTintList( buildColorStateList( - android.R.attr.state_checked to dynamicColor.onSecondaryContainer().getArgb(scheme), - -android.R.attr.state_checked to dynamicColor.onSurfaceVariant().getArgb(scheme) + android.R.attr.state_checked to dynamicColor.secondaryContainer().getArgb(scheme), + -android.R.attr.state_checked to Color.TRANSPARENT ) - - navigationView.itemTextColor = colorStateList - if (colorIcons) { - navigationView.itemIconTintList = colorStateList - } + ) } - } + navigationView.background.setTintList(ColorStateList.valueOf(dynamicColor.surface().getArgb(scheme))) - fun getPrimaryColorDrawable(context: Context): Drawable = - withScheme(context) { scheme -> - ColorDrawable(dynamicColor.primary().getArgb(scheme)) - } + val colorStateList = + buildColorStateList( + android.R.attr.state_checked to dynamicColor.onSecondaryContainer().getArgb(scheme), + -android.R.attr.state_checked to dynamicColor.onSurfaceVariant().getArgb(scheme) + ) - fun colorToolbarMenuIcon( - context: Context, - item: MenuItem - ) { - withScheme(context) { scheme -> - colorMenuItemIcon(dynamicColor.onSurfaceVariant().getArgb(scheme), item) + navigationView.itemTextColor = colorStateList + if (colorIcons) { + navigationView.itemIconTintList = colorStateList } } + } - fun colorMenuItemText( - context: Context, - item: MenuItem - ) { - withScheme(context) { scheme: DynamicScheme -> - colorMenuItemText(dynamicColor.onSurface().getArgb(scheme), item) - } - } + fun getPrimaryColorDrawable(context: Context): Drawable = withScheme(context) { scheme -> + ColorDrawable(dynamicColor.primary().getArgb(scheme)) + } - private fun colorMenuItemIcon( - @ColorInt color: Int, - item: MenuItem - ) { - item.icon?.setTint(color) + fun colorToolbarMenuIcon(context: Context, item: MenuItem) { + withScheme(context) { scheme -> + colorMenuItemIcon(dynamicColor.onSurfaceVariant().getArgb(scheme), item) } + } - private fun colorMenuItemText( - @ColorInt color: Int, - item: MenuItem - ) { - val newItemTitle = SpannableString(item.title) - newItemTitle.setSpan( - ForegroundColorSpan(color), - 0, - newItemTitle.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - item.title = newItemTitle - } - - @JvmOverloads - fun tintDrawable( - context: Context, - @DrawableRes id: Int, - colorRole: ColorRole = ColorRole.PRIMARY - ): Drawable? { - val drawable = ResourcesCompat.getDrawable(context.resources, id, null) - return drawable?.let { - tintDrawable(context, it, colorRole) - } + fun colorMenuItemText(context: Context, item: MenuItem) { + withScheme(context) { scheme: DynamicScheme -> + colorMenuItemText(dynamicColor.onSurface().getArgb(scheme), item) } + } - @Deprecated( - replaceWith = - ReplaceWith( - "tintDrawable(context, id, ColorRole.PRIMARY)", - imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] - ), - message = "Use tintDrawable(context, id, ColorRole.PRIMARY) instead" - ) - fun tintPrimaryDrawable( - context: Context, - @DrawableRes id: Int - ): Drawable? = tintDrawable(context, id, ColorRole.PRIMARY) - - @JvmOverloads - fun tintDrawable( - context: Context, - drawable: Drawable, - colorRole: ColorRole = ColorRole.PRIMARY - ): Drawable = - withScheme(context) { scheme: DynamicScheme -> - colorDrawable(drawable, colorRole.select(scheme)) - } + private fun colorMenuItemIcon(@ColorInt color: Int, item: MenuItem) { + item.icon?.setTint(color) + } - @Deprecated( - replaceWith = - ReplaceWith( - "tintDrawable(context, drawable, ColorRole.PRIMARY)", - imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] - ), - message = "Use tintDrawable(context, drawable, ColorRole.PRIMARY) instead" + private fun colorMenuItemText(@ColorInt color: Int, item: MenuItem) { + val newItemTitle = SpannableString(item.title) + newItemTitle.setSpan( + ForegroundColorSpan(color), + 0, + newItemTitle.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) - fun tintPrimaryDrawable( - context: Context, - drawable: Drawable? - ): Drawable? = drawable?.let { tintDrawable(context, it, ColorRole.PRIMARY) } - - @Deprecated( - replaceWith = - ReplaceWith( - "tintDrawable(context, drawable, ColorRole.ON_SURFACE)", - imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] - ), - message = "Use tintDrawable(context, drawable, ColorRole.ON_SURFACE) instead" - ) - fun tintTextDrawable( - context: Context, - drawable: Drawable? - ): Drawable? = drawable?.let { tintDrawable(context, it, ColorRole.ON_SURFACE) } - - /** - * Public for edge cases. For most cases use [tintDrawable] instead - */ - fun colorDrawable( - drawable: Drawable, - @ColorInt color: Int - ): Drawable { + item.title = newItemTitle + } + + @JvmOverloads + fun tintDrawable(context: Context, @DrawableRes id: Int, colorRole: ColorRole = ColorRole.PRIMARY): Drawable? { + val drawable = ResourcesCompat.getDrawable(context.resources, id, null) + return drawable?.let { + tintDrawable(context, it, colorRole) + } + } + + @Deprecated( + replaceWith = + ReplaceWith( + "tintDrawable(context, id, ColorRole.PRIMARY)", + imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] + ), + message = "Use tintDrawable(context, id, ColorRole.PRIMARY) instead" + ) + fun tintPrimaryDrawable(context: Context, @DrawableRes id: Int): Drawable? = + tintDrawable(context, id, ColorRole.PRIMARY) + + @JvmOverloads + fun tintDrawable(context: Context, drawable: Drawable, colorRole: ColorRole = ColorRole.PRIMARY): Drawable = + withScheme(context) { scheme: DynamicScheme -> + colorDrawable(drawable, colorRole.select(scheme)) + } + + @Deprecated( + replaceWith = + ReplaceWith( + "tintDrawable(context, drawable, ColorRole.PRIMARY)", + imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] + ), + message = "Use tintDrawable(context, drawable, ColorRole.PRIMARY) instead" + ) + fun tintPrimaryDrawable(context: Context, drawable: Drawable?): Drawable? = + drawable?.let { tintDrawable(context, it, ColorRole.PRIMARY) } + + @Deprecated( + replaceWith = + ReplaceWith( + "tintDrawable(context, drawable, ColorRole.ON_SURFACE)", + imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] + ), + message = "Use tintDrawable(context, drawable, ColorRole.ON_SURFACE) instead" + ) + fun tintTextDrawable(context: Context, drawable: Drawable?): Drawable? = + drawable?.let { tintDrawable(context, it, ColorRole.ON_SURFACE) } + + /** + * Public for edge cases. For most cases use [tintDrawable] instead + */ + fun colorDrawable(drawable: Drawable, @ColorInt color: Int): Drawable { + val wrap = DrawableCompat.wrap(drawable) + wrap.colorFilter = + BlendModeColorFilterCompat.createBlendModeColorFilterCompat( + color, + BlendModeCompat.SRC_ATOP + ) + return wrap + } + + fun tintToolbarArrowDrawable(context: Context, drawerToggle: ActionBarDrawerToggle, drawable: Drawable) { + withScheme(context) { scheme: DynamicScheme -> val wrap = DrawableCompat.wrap(drawable) wrap.colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat( - color, + dynamicColor.onSurface().getArgb(scheme), BlendModeCompat.SRC_ATOP ) - return wrap - } - - fun tintToolbarArrowDrawable( - context: Context, - drawerToggle: ActionBarDrawerToggle, - drawable: Drawable - ) { - withScheme(context) { scheme: DynamicScheme -> - val wrap = DrawableCompat.wrap(drawable) - wrap.colorFilter = - BlendModeColorFilterCompat.createBlendModeColorFilterCompat( - dynamicColor.onSurface().getArgb(scheme), - BlendModeCompat.SRC_ATOP - ) - drawerToggle.setHomeAsUpIndicator(wrap) - drawerToggle.drawerArrowDrawable.color = dynamicColor.onSurface().getArgb(scheme) - } - } - - fun themeStatusBar(activity: Activity) { - themeStatusBar(activity, ColorRole.SURFACE) + drawerToggle.setHomeAsUpIndicator(wrap) + drawerToggle.drawerArrowDrawable.color = dynamicColor.onSurface().getArgb(scheme) } + } - fun themeStatusBar( - activity: Activity, - colorRole: ColorRole - ) { - withScheme(activity) { scheme -> - colorStatusBar(activity, colorRole.select(scheme)) - } - } + fun themeStatusBar(activity: Activity) { + themeStatusBar(activity, ColorRole.SURFACE) + } - /** - * Public for special cases, e.g. action mode. You probably want [themeStatusBar] for most cases instead. - */ - fun colorStatusBar( - activity: Activity, - @ColorInt color: Int - ) { - val window = activity.window ?: return - val isLightBackground = !colorUtil.isDarkBackground(color) - val decor = window.decorView - window.statusBarColor = color - window.navigationBarColor = color - WindowInsetsControllerCompat(window, decor).isAppearanceLightStatusBars = isLightBackground - WindowInsetsControllerCompat(window, decor).isAppearanceLightNavigationBars = isLightBackground - } - - fun resetStatusBar(activity: Activity) { - colorStatusBar( - activity, - ResourcesCompat.getColor( - activity.resources, - R.color.bg_default, - activity.theme - ) - ) + fun themeStatusBar(activity: Activity, colorRole: ColorRole) { + withScheme(activity) { scheme -> + colorStatusBar(activity, colorRole.select(scheme)) } + } - fun themeDialog(view: View) { - withScheme(view) { scheme -> - view.setBackgroundColor(dynamicColor.surface().getArgb(scheme)) - } - } + /** + * Public for special cases, e.g. action mode. You probably want [themeStatusBar] for most cases instead. + */ + fun colorStatusBar(activity: Activity, @ColorInt color: Int) { + val window = activity.window ?: return + val isLightBackground = !colorUtil.isDarkBackground(color) + val decor = window.decorView + window.statusBarColor = color + window.navigationBarColor = color + WindowInsetsControllerCompat(window, decor).isAppearanceLightStatusBars = isLightBackground + WindowInsetsControllerCompat(window, decor).isAppearanceLightNavigationBars = isLightBackground + } - fun themeDialogDark(view: View) { - withSchemeDark { scheme -> - view.setBackgroundColor(dynamicColor.surface().getArgb(scheme)) - } - } + fun resetStatusBar(activity: Activity) { + colorStatusBar( + activity, + ResourcesCompat.getColor( + activity.resources, + R.color.bg_default, + activity.theme + ) + ) + } - fun themeDialogDivider(view: View) { - withScheme(view) { scheme -> - view.setBackgroundColor(dynamicColor.surfaceVariant().getArgb(scheme)) - } + fun themeDialog(view: View) { + withScheme(view) { scheme -> + view.setBackgroundColor(dynamicColor.surface().getArgb(scheme)) } + } - fun themeHorizontalSeekBar(seekBar: SeekBar) { - withScheme(seekBar) { scheme -> - themeHorizontalProgressBar(seekBar, dynamicColor.primary().getArgb(scheme)) - seekBar.thumb.colorFilter = - BlendModeColorFilterCompat.createBlendModeColorFilterCompat( - dynamicColor.primary().getArgb(scheme), - BlendModeCompat.SRC_IN - ) - } + fun themeDialogDark(view: View) { + withSchemeDark { scheme -> + view.setBackgroundColor(dynamicColor.surface().getArgb(scheme)) } + } - fun themeHorizontalProgressBar(progressBar: ProgressBar) { - withScheme(progressBar) { scheme -> - themeHorizontalProgressBar(progressBar, dynamicColor.primary().getArgb(scheme)) - } + fun themeDialogDivider(view: View) { + withScheme(view) { scheme -> + view.setBackgroundColor(dynamicColor.surfaceVariant().getArgb(scheme)) } + } - fun themeHorizontalProgressBar( - progressBar: ProgressBar?, - @ColorInt color: Int - ) { - progressBar?.indeterminateDrawable?.colorFilter = - BlendModeColorFilterCompat.createBlendModeColorFilterCompat( - color, - BlendModeCompat.SRC_IN - ) - progressBar?.progressDrawable?.colorFilter = + fun themeHorizontalSeekBar(seekBar: SeekBar) { + withScheme(seekBar) { scheme -> + themeHorizontalProgressBar(seekBar, dynamicColor.primary().getArgb(scheme)) + seekBar.thumb.colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat( - color, + dynamicColor.primary().getArgb(scheme), BlendModeCompat.SRC_IN ) } + } - @JvmOverloads - fun colorTextView( - textView: TextView, - colorRole: ColorRole = ColorRole.PRIMARY - ) { - withScheme(textView) { scheme -> - textView.setTextColor(colorRole.select(scheme)) - } + fun themeHorizontalProgressBar(progressBar: ProgressBar) { + withScheme(progressBar) { scheme -> + themeHorizontalProgressBar(progressBar, dynamicColor.primary().getArgb(scheme)) } + } - @JvmOverloads - fun colorTextViewLinks( - textView: TextView, - colorRole: ColorRole = ColorRole.PRIMARY - ) { - withScheme(textView.context) { scheme -> - textView.setLinkTextColor(colorRole.select(scheme)) - } + fun themeHorizontalProgressBar(progressBar: ProgressBar?, @ColorInt color: Int) { + progressBar?.indeterminateDrawable?.colorFilter = + BlendModeColorFilterCompat.createBlendModeColorFilterCompat( + color, + BlendModeCompat.SRC_IN + ) + progressBar?.progressDrawable?.colorFilter = + BlendModeColorFilterCompat.createBlendModeColorFilterCompat( + color, + BlendModeCompat.SRC_IN + ) + } + + @JvmOverloads + fun colorTextView(textView: TextView, colorRole: ColorRole = ColorRole.PRIMARY) { + withScheme(textView) { scheme -> + textView.setTextColor(colorRole.select(scheme)) } + } - @Deprecated( - replaceWith = ReplaceWith("colorTextView(textView)"), - message = "Use colorTextView(textView) instead" - ) - fun colorPrimaryTextViewElement(textView: TextView) { - colorTextView(textView, ColorRole.PRIMARY) + @JvmOverloads + fun colorTextViewLinks(textView: TextView, colorRole: ColorRole = ColorRole.PRIMARY) { + withScheme(textView.context) { scheme -> + textView.setLinkTextColor(colorRole.select(scheme)) } + } - @Deprecated( - replaceWith = - ReplaceWith( - "colorTextView(textView, ColorRole.ON_SECONDARY_CONTAINER)", - imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] - ), - message = "Use colorTextView(textView, ColorRole.ON_SECONDARY_CONTAINER) instead" - ) - fun colorOnSecondaryContainerTextViewElement(textView: TextView) { - colorTextView(textView, ColorRole.ON_SECONDARY_CONTAINER) + @Deprecated( + replaceWith = ReplaceWith("colorTextView(textView)"), + message = "Use colorTextView(textView) instead" + ) + fun colorPrimaryTextViewElement(textView: TextView) { + colorTextView(textView, ColorRole.PRIMARY) + } + + @Deprecated( + replaceWith = + ReplaceWith( + "colorTextView(textView, ColorRole.ON_SECONDARY_CONTAINER)", + imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] + ), + message = "Use colorTextView(textView, ColorRole.ON_SECONDARY_CONTAINER) instead" + ) + fun colorOnSecondaryContainerTextViewElement(textView: TextView) { + colorTextView(textView, ColorRole.ON_SECONDARY_CONTAINER) + } + + fun colorPrimaryTextViewElementDarkMode(textView: TextView) { + withSchemeDark { scheme -> + textView.setTextColor(dynamicColor.primary().getArgb(scheme)) + } + } + + @Deprecated( + replaceWith = ReplaceWith("colorViewBackground(view)"), + message = "Use colorViewBackground(view) instead" + ) + fun colorPrimaryView(view: View) { + withScheme(view) { scheme -> + view.setBackgroundColor(dynamicColor.primary().getArgb(scheme)) } + } - fun colorPrimaryTextViewElementDarkMode(textView: TextView) { - withSchemeDark { scheme -> - textView.setTextColor(dynamicColor.primary().getArgb(scheme)) - } + /** + * Colors the background as element color and the foreground as text color. + * + */ + @Deprecated( + replaceWith = ReplaceWith("colorImageViewBackgroundAndIcon"), + message = "Use colorImageViewBackgroundAndIcon, which has a better name, instead" + ) + fun colorImageViewButton(imageView: ImageView) { + colorImageViewBackgroundAndIcon(imageView) + } + + /** + * Colors the background as element color and the foreground as text color. + */ + fun colorImageViewBackgroundAndIcon(imageView: ImageView) { + withScheme(imageView) { scheme -> + imageView.imageTintList = ColorStateList.valueOf(dynamicColor.onPrimaryContainer().getArgb(scheme)) + imageView.backgroundTintList = ColorStateList.valueOf(dynamicColor.primaryContainer().getArgb(scheme)) } + } - @Deprecated( - replaceWith = ReplaceWith("colorViewBackground(view)"), - message = "Use colorViewBackground(view) instead" - ) - fun colorPrimaryView(view: View) { - withScheme(view) { scheme -> - view.setBackgroundColor(dynamicColor.primary().getArgb(scheme)) - } + fun themeImageButton(imageButton: ImageButton) { + withScheme(imageButton) { scheme -> + imageButton.imageTintList = + buildColorStateList( + android.R.attr.state_selected to dynamicColor.primary().getArgb(scheme), + -android.R.attr.state_selected to dynamicColor.onSurfaceVariant().getArgb(scheme), + android.R.attr.state_enabled to dynamicColor.onSurfaceVariant().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + ON_SURFACE_OPACITY_BUTTON_DISABLED + ) + ) } + } - /** - * Colors the background as element color and the foreground as text color. - * - */ - @Deprecated( - replaceWith = ReplaceWith("colorImageViewBackgroundAndIcon"), - message = "Use colorImageViewBackgroundAndIcon, which has a better name, instead" - ) - fun colorImageViewButton(imageView: ImageView) { - colorImageViewBackgroundAndIcon(imageView) + /** + * In most cases you'll want to use [themeImageButton] instead. + */ + fun colorImageButton(imageButton: ImageButton?, @ColorInt color: Int) { + imageButton?.setColorFilter(color, PorterDuff.Mode.SRC_ATOP) + } + + /** + * Tints the image with element color + */ + @Deprecated( + replaceWith = ReplaceWith("colorImageView(imageView, ColorRole.PRIMARY)"), + message = "Use colorImageView(imageView, ColorRole.PRIMARY) instead" + ) + fun colorImageView(imageView: ImageView) { + colorImageView(imageView, ColorRole.PRIMARY) + } + + fun colorImageView(imageView: ImageView, colorRole: ColorRole) { + withScheme(imageView) { scheme -> + imageView.imageTintList = ColorStateList.valueOf(colorRole.select(scheme)) } + } - /** - * Colors the background as element color and the foreground as text color. - */ - fun colorImageViewBackgroundAndIcon(imageView: ImageView) { - withScheme(imageView) { scheme -> - imageView.imageTintList = ColorStateList.valueOf(dynamicColor.onPrimaryContainer().getArgb(scheme)) - imageView.backgroundTintList = ColorStateList.valueOf(dynamicColor.primaryContainer().getArgb(scheme)) - } + fun colorTextButtons(vararg buttons: Button) { + withScheme(buttons[0]) { scheme -> + colorTextButtons(dynamicColor.primary().getArgb(scheme), *buttons) } + } - fun themeImageButton(imageButton: ImageButton) { - withScheme(imageButton) { scheme -> - imageButton.imageTintList = + /** + * In most cases you'll want to use [colorTextButtons] instead. + */ + fun colorTextButtons(@ColorInt color: Int, vararg buttons: Button) { + withScheme(buttons[0]) { scheme -> + for (button in buttons) { + button.setTextColor( buildColorStateList( - android.R.attr.state_selected to dynamicColor.primary().getArgb(scheme), - -android.R.attr.state_selected to dynamicColor.onSurfaceVariant().getArgb(scheme), - android.R.attr.state_enabled to dynamicColor.onSurfaceVariant().getArgb(scheme), + android.R.attr.state_enabled to color, -android.R.attr.state_enabled to colorUtil.adjustOpacity( dynamicColor.onSurface().getArgb(scheme), ON_SURFACE_OPACITY_BUTTON_DISABLED ) ) + ) } } + } - /** - * In most cases you'll want to use [themeImageButton] instead. - */ - fun colorImageButton( - imageButton: ImageButton?, - @ColorInt color: Int - ) { - imageButton?.setColorFilter(color, PorterDuff.Mode.SRC_ATOP) - } - - /** - * Tints the image with element color - */ - @Deprecated( - replaceWith = ReplaceWith("colorImageView(imageView, ColorRole.PRIMARY)"), - message = "Use colorImageView(imageView, ColorRole.PRIMARY) instead" - ) - fun colorImageView(imageView: ImageView) { - colorImageView(imageView, ColorRole.PRIMARY) - } - - fun colorImageView( - imageView: ImageView, - colorRole: ColorRole - ) { - withScheme(imageView) { scheme -> - imageView.imageTintList = ColorStateList.valueOf(colorRole.select(scheme)) - } - } - - fun colorTextButtons(vararg buttons: Button) { - withScheme(buttons[0]) { scheme -> - colorTextButtons(dynamicColor.primary().getArgb(scheme), *buttons) - } - } - - /** - * In most cases you'll want to use [colorTextButtons] instead. - */ - fun colorTextButtons( - @ColorInt color: Int, - vararg buttons: Button - ) { - withScheme(buttons[0]) { scheme -> - for (button in buttons) { - button.setTextColor( - buildColorStateList( - android.R.attr.state_enabled to color, - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - ON_SURFACE_OPACITY_BUTTON_DISABLED - ) - ) - ) - } - } - } + @Deprecated( + replaceWith = + ReplaceWith( + "colorCircularProgressBar(progressBar, ColorRole.ON_PRIMARY_CONTAINER)", + imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] + ), + message = "Use colorCircularProgressBar(progressBar, ColorRole.ON_PRIMARY_CONTAINER) instead" + ) + fun colorCircularProgressBarOnPrimaryContainer(progressBar: ProgressBar) { + colorCircularProgressBar(progressBar, ColorRole.ON_PRIMARY_CONTAINER) + } - @Deprecated( - replaceWith = - ReplaceWith( - "colorCircularProgressBar(progressBar, ColorRole.ON_PRIMARY_CONTAINER)", - imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] - ), - message = "Use colorCircularProgressBar(progressBar, ColorRole.ON_PRIMARY_CONTAINER) instead" - ) - fun colorCircularProgressBarOnPrimaryContainer(progressBar: ProgressBar) { - colorCircularProgressBar(progressBar, ColorRole.ON_PRIMARY_CONTAINER) - } + @Deprecated( + replaceWith = + ReplaceWith( + "colorCircularProgressBar(progressBar, ColorRole.PRIMARY)", + imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] + ), + message = "Use colorCircularProgressBar(progressBar, ColorRole.PRIMARY) instead" + ) + fun colorCircularProgressBar(progressBar: ProgressBar) { + colorCircularProgressBar(progressBar, ColorRole.PRIMARY) + } - @Deprecated( - replaceWith = - ReplaceWith( - "colorCircularProgressBar(progressBar, ColorRole.PRIMARY)", - imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] - ), - message = "Use colorCircularProgressBar(progressBar, ColorRole.PRIMARY) instead" - ) - fun colorCircularProgressBar(progressBar: ProgressBar) { - colorCircularProgressBar(progressBar, ColorRole.PRIMARY) - } + @Deprecated( + replaceWith = + ReplaceWith( + "colorCircularProgressBar(progressBar, ColorRole.ON_SURFACE_VARIANT)", + imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] + ), + message = "Use colorCircularProgressBar(progressBar, ColorRole.ON_SURFACE_VARIANT) instead" + ) + fun colorCircularProgressBarOnSurfaceVariant(progressBar: ProgressBar) { + colorCircularProgressBar(progressBar, ColorRole.ON_SURFACE_VARIANT) + } - @Deprecated( - replaceWith = - ReplaceWith( - "colorCircularProgressBar(progressBar, ColorRole.ON_SURFACE_VARIANT)", - imports = ["com.nextcloud.android.common.ui.theme.utils.ColorRole"] - ), - message = "Use colorCircularProgressBar(progressBar, ColorRole.ON_SURFACE_VARIANT) instead" - ) - fun colorCircularProgressBarOnSurfaceVariant(progressBar: ProgressBar) { - colorCircularProgressBar(progressBar, ColorRole.ON_SURFACE_VARIANT) - } - - fun colorCircularProgressBar( - progressBar: ProgressBar, - colorRole: ColorRole - ) { - withScheme(progressBar) { scheme -> - progressBar.indeterminateDrawable.colorFilter = - BlendModeColorFilterCompat.createBlendModeColorFilterCompat( - colorRole.select(scheme), - BlendModeCompat.SRC_ATOP - ) - } + fun colorCircularProgressBar(progressBar: ProgressBar, colorRole: ColorRole) { + withScheme(progressBar) { scheme -> + progressBar.indeterminateDrawable.colorFilter = + BlendModeColorFilterCompat.createBlendModeColorFilterCompat( + colorRole.select(scheme), + BlendModeCompat.SRC_ATOP + ) } + } - fun themeCheckedTextView(vararg checkedTextViews: CheckedTextView) { - withScheme(checkedTextViews[0]) { scheme -> - val colorStateList = - buildColorStateList( - -android.R.attr.state_checked to Color.GRAY, - -android.R.attr.state_enabled to Color.GRAY, - android.R.attr.state_checked to dynamicColor.primary().getArgb(scheme) - ) + fun themeCheckedTextView(vararg checkedTextViews: CheckedTextView) { + withScheme(checkedTextViews[0]) { scheme -> + val colorStateList = + buildColorStateList( + -android.R.attr.state_checked to Color.GRAY, + -android.R.attr.state_enabled to Color.GRAY, + android.R.attr.state_checked to dynamicColor.primary().getArgb(scheme) + ) - checkedTextViews.forEach { - it.checkMarkTintList = colorStateList - } + checkedTextViews.forEach { + it.checkMarkTintList = colorStateList } } + } - fun themeCheckbox(vararg checkboxes: CheckBox) { - withScheme(checkboxes[0]) { scheme -> - val colorStateList = - buildColorStateList( - -android.R.attr.state_checked to Color.GRAY, - -android.R.attr.state_enabled to Color.GRAY, - android.R.attr.state_checked to dynamicColor.primary().getArgb(scheme) - ) - checkboxes.forEach { - it.buttonTintList = colorStateList - } + fun themeCheckbox(vararg checkboxes: CheckBox) { + withScheme(checkboxes[0]) { scheme -> + val colorStateList = + buildColorStateList( + -android.R.attr.state_checked to Color.GRAY, + -android.R.attr.state_enabled to Color.GRAY, + android.R.attr.state_checked to dynamicColor.primary().getArgb(scheme) + ) + checkboxes.forEach { + it.buttonTintList = colorStateList } } + } - fun themeRadioButton(radioButton: RadioButton) { - withScheme(radioButton) { scheme -> - radioButton.buttonTintList = - buildColorStateList( - -android.R.attr.state_checked to Color.GRAY, - android.R.attr.state_checked to dynamicColor.primary().getArgb(scheme) - ) - } + fun themeRadioButton(radioButton: RadioButton) { + withScheme(radioButton) { scheme -> + radioButton.buttonTintList = + buildColorStateList( + -android.R.attr.state_checked to Color.GRAY, + android.R.attr.state_checked to dynamicColor.primary().getArgb(scheme) + ) } + } - fun colorEditText(editText: EditText) { - withScheme(editText) { scheme -> - // TODO check API-level compatibility - // editText.background.setColorFilter(color, PorterDuff.Mode.SRC_ATOP) - editText.backgroundTintList = - buildColorStateList( - -android.R.attr.state_focused to dynamicColor.outline().getArgb(scheme), - android.R.attr.state_focused to dynamicColor.primary().getArgb(scheme) - ) + fun colorEditText(editText: EditText) { + withScheme(editText) { scheme -> + // TODO check API-level compatibility + // editText.background.setColorFilter(color, PorterDuff.Mode.SRC_ATOP) + editText.backgroundTintList = + buildColorStateList( + -android.R.attr.state_focused to dynamicColor.outline().getArgb(scheme), + android.R.attr.state_focused to dynamicColor.primary().getArgb(scheme) + ) - editText.setHintTextColor(dynamicColor.onSurfaceVariant().getArgb(scheme)) - editText.setTextColor(dynamicColor.onSurface().getArgb(scheme)) - } + editText.setHintTextColor(dynamicColor.onSurfaceVariant().getArgb(scheme)) + editText.setTextColor(dynamicColor.onSurface().getArgb(scheme)) } + } - fun colorEditTextOnPrimary(editText: EditText) { - withScheme(editText) { scheme -> - // TODO check API-level compatibility - editText.setHintTextColor(dynamicColor.onPrimary().getArgb(scheme)) - editText.setTextColor(dynamicColor.onPrimary().getArgb(scheme)) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - editText.textCursorDrawable?.let { - editText.textCursorDrawable = colorDrawable(it, dynamicColor.onPrimary().getArgb(scheme)) - } + fun colorEditTextOnPrimary(editText: EditText) { + withScheme(editText) { scheme -> + // TODO check API-level compatibility + editText.setHintTextColor(dynamicColor.onPrimary().getArgb(scheme)) + editText.setTextColor(dynamicColor.onPrimary().getArgb(scheme)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + editText.textCursorDrawable?.let { + editText.textCursorDrawable = colorDrawable(it, dynamicColor.onPrimary().getArgb(scheme)) } } } + } - fun themeViewBackground( - view: View, - colorRole: ColorRole = ColorRole.SURFACE - ) { - withScheme(view) { scheme -> - view.setBackgroundColor(colorRole.select(scheme)) - } + fun themeViewBackground(view: View, colorRole: ColorRole = ColorRole.SURFACE) { + withScheme(view) { scheme -> + view.setBackgroundColor(colorRole.select(scheme)) } + } - @JvmOverloads - fun highlightText( - textView: TextView, - originalText: String, - constraint: String, - colorRole: ColorRole = ColorRole.PRIMARY - ) { - withScheme(textView) { scheme -> - highlightText(textView, originalText, constraint, colorRole.select(scheme)) - } + @JvmOverloads + fun highlightText( + textView: TextView, + originalText: String, + constraint: String, + colorRole: ColorRole = ColorRole.PRIMARY + ) { + withScheme(textView) { scheme -> + highlightText(textView, originalText, constraint, colorRole.select(scheme)) } + } - private fun highlightText( - textView: TextView, - originalText: String, - constraint: String, - @ColorInt color: Int - ) { - val constraintLower = constraint.lowercase() - val start: Int = originalText.lowercase().indexOf(constraintLower) - if (start != -1) { - val spanText = Spannable.Factory.getInstance().newSpannable(originalText) - spanText(originalText, constraintLower, color, start, spanText) - textView.setText(spanText, TextView.BufferType.SPANNABLE) - } else { - textView.setText(originalText, TextView.BufferType.NORMAL) - } + private fun highlightText(textView: TextView, originalText: String, constraint: String, @ColorInt color: Int) { + val constraintLower = constraint.lowercase() + val start: Int = originalText.lowercase().indexOf(constraintLower) + if (start != -1) { + val spanText = Spannable.Factory.getInstance().newSpannable(originalText) + spanText(originalText, constraintLower, color, start, spanText) + textView.setText(spanText, TextView.BufferType.SPANNABLE) + } else { + textView.setText(originalText, TextView.BufferType.NORMAL) } + } - private fun spanText( - originalText: String, - constraint: String, - @ColorInt color: Int, - start: Int, - spanText: Spannable - ) { - val matcher = - Pattern - .compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL) - .matcher(originalText) - - matcher.find(start) - - do { - val mStart = matcher.start() - val mEnd = matcher.end() - spanText.setSpan(ForegroundColorSpan(color), mStart, mEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - spanText.setSpan(StyleSpan(Typeface.BOLD), mStart, mEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } while (matcher.find()) - } - - // here for backwards compatibility - @SuppressLint("UseSwitchCompatOrMaterialCode") - fun colorSwitch(switch: Switch) { - withScheme(switch) { scheme -> - val colors = SwitchColorUtils.calculateSwitchColors(switch.context, scheme) - DrawableCompat.setTintList(switch.thumbDrawable, colors.thumbColor) - DrawableCompat.setTintList(switch.trackDrawable, colors.trackColor) - } + private fun spanText( + originalText: String, + constraint: String, + @ColorInt color: Int, + start: Int, + spanText: Spannable + ) { + val matcher = + Pattern + .compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL) + .matcher(originalText) + + matcher.find(start) + + do { + val mStart = matcher.start() + val mEnd = matcher.end() + spanText.setSpan(ForegroundColorSpan(color), mStart, mEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + spanText.setSpan(StyleSpan(Typeface.BOLD), mStart, mEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } while (matcher.find()) + } + + // here for backwards compatibility + @SuppressLint("UseSwitchCompatOrMaterialCode") + fun colorSwitch(switch: Switch) { + withScheme(switch) { scheme -> + val colors = SwitchColorUtils.calculateSwitchColors(switch.context, scheme) + DrawableCompat.setTintList(switch.thumbDrawable, colors.thumbColor) + DrawableCompat.setTintList(switch.trackDrawable, colors.trackColor) } + } - @Deprecated("Don't do this, implement custom viewThemeUtils instead") - fun primaryColor(activity: Activity): Int = - withScheme(activity) { scheme -> - dynamicColor.primary().getArgb(scheme) - } + @Deprecated("Don't do this, implement custom viewThemeUtils instead") + fun primaryColor(activity: Activity): Int = withScheme(activity) { scheme -> + dynamicColor.primary().getArgb(scheme) + } - companion object { - private const val ON_SURFACE_OPACITY_BUTTON_DISABLED: Float = 0.38f - } + companion object { + private const val ON_SURFACE_OPACITY_BUTTON_DISABLED: Float = 0.38f } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/AndroidXViewThemeUtils.kt b/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/AndroidXViewThemeUtils.kt index 48d517c7..9cfd1c24 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/AndroidXViewThemeUtils.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/AndroidXViewThemeUtils.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022-2023 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2022-2023 Andy Scherzinger * SPDX-FileCopyrightText: 2023 Thore Goebel @@ -36,111 +37,93 @@ import javax.inject.Inject * View theme utils for Android extension views (androidx.*) */ class AndroidXViewThemeUtils - @Inject - constructor( - schemes: MaterialSchemes, - private val androidViewThemeUtils: AndroidViewThemeUtils - ) : ViewThemeUtilsBase(schemes) { - private val dynamicColor = MaterialDynamicColors() +@Inject +constructor( + schemes: MaterialSchemes, + private val androidViewThemeUtils: AndroidViewThemeUtils +) : ViewThemeUtilsBase(schemes) { + private val dynamicColor = MaterialDynamicColors() - fun colorSwitchCompat(switchCompat: SwitchCompat) { - withScheme(switchCompat) { scheme -> - val colors = SwitchColorUtils.calculateSwitchColors(switchCompat.context, scheme) - switchCompat.thumbTintList = colors.thumbColor - switchCompat.trackTintList = colors.trackColor - } + fun colorSwitchCompat(switchCompat: SwitchCompat) { + withScheme(switchCompat) { scheme -> + val colors = SwitchColorUtils.calculateSwitchColors(switchCompat.context, scheme) + switchCompat.thumbTintList = colors.thumbColor + switchCompat.trackTintList = colors.trackColor } + } - fun themeSwipeRefreshLayout(swipeRefreshLayout: SwipeRefreshLayout) { - withScheme(swipeRefreshLayout) { scheme -> - swipeRefreshLayout.setColorSchemeColors(dynamicColor.primary().getArgb(scheme)) - swipeRefreshLayout.setProgressBackgroundColorSchemeResource(R.color.refresh_spinner_background) - } + fun themeSwipeRefreshLayout(swipeRefreshLayout: SwipeRefreshLayout) { + withScheme(swipeRefreshLayout) { scheme -> + swipeRefreshLayout.setColorSchemeColors(dynamicColor.primary().getArgb(scheme)) + swipeRefreshLayout.setProgressBackgroundColorSchemeResource(R.color.refresh_spinner_background) } + } - fun colorPrimaryTextViewElement(textView: AppCompatTextView) { - withScheme(textView) { scheme -> - textView.setTextColor(dynamicColor.primary().getArgb(scheme)) - TextViewCompat.setCompoundDrawableTintList( - textView, - ColorStateList.valueOf(dynamicColor.primary().getArgb(scheme)) - ) - } + fun colorPrimaryTextViewElement(textView: AppCompatTextView) { + withScheme(textView) { scheme -> + textView.setTextColor(dynamicColor.primary().getArgb(scheme)) + TextViewCompat.setCompoundDrawableTintList( + textView, + ColorStateList.valueOf(dynamicColor.primary().getArgb(scheme)) + ) } + } - // TODO host the back arrow in this lib instead of passing it everywhere - fun themeActionBar( - context: Context, - actionBar: ActionBar, - title: String, - backArrow: Drawable - ) { - withScheme(context) { scheme -> - val text: Spannable = getColoredSpan(title, dynamicColor.onSurface().getArgb(scheme)) - actionBar.title = text - themeActionBar(context, actionBar, backArrow) - } + // TODO host the back arrow in this lib instead of passing it everywhere + fun themeActionBar(context: Context, actionBar: ActionBar, title: String, backArrow: Drawable) { + withScheme(context) { scheme -> + val text: Spannable = getColoredSpan(title, dynamicColor.onSurface().getArgb(scheme)) + actionBar.title = text + themeActionBar(context, actionBar, backArrow) } + } - fun themeActionBar( - context: Context, - actionBar: ActionBar, - backArrow: Drawable - ) { - withScheme(context) { scheme -> - actionBar.setBackgroundDrawable(ColorDrawable(dynamicColor.surface().getArgb(scheme))) - val indicator = androidViewThemeUtils.colorDrawable(backArrow, dynamicColor.onSurface().getArgb(scheme)) - actionBar.setHomeAsUpIndicator(indicator) - } + fun themeActionBar(context: Context, actionBar: ActionBar, backArrow: Drawable) { + withScheme(context) { scheme -> + actionBar.setBackgroundDrawable(ColorDrawable(dynamicColor.surface().getArgb(scheme))) + val indicator = androidViewThemeUtils.colorDrawable(backArrow, dynamicColor.onSurface().getArgb(scheme)) + actionBar.setHomeAsUpIndicator(indicator) } + } - fun themeActionBarSubtitle( - context: Context, - actionBar: ActionBar - ) { - withScheme(context) { scheme -> - actionBar.subtitle = - getColoredSpan(actionBar.subtitle.toString(), dynamicColor.onSurfaceVariant().getArgb(scheme)) - } + fun themeActionBarSubtitle(context: Context, actionBar: ActionBar) { + withScheme(context) { scheme -> + actionBar.subtitle = + getColoredSpan(actionBar.subtitle.toString(), dynamicColor.onSurfaceVariant().getArgb(scheme)) } + } - fun themeToolbarSearchView(searchView: SearchView) { - withScheme(searchView) { scheme -> - // hacky as no default way is provided - val editText = - searchView.findViewById(androidx.appcompat.R.id.search_src_text) - val searchPlate = searchView.findViewById(androidx.appcompat.R.id.search_plate) - val closeButton = searchView.findViewById(androidx.appcompat.R.id.search_close_btn) - val searchButton = searchView.findViewById(androidx.appcompat.R.id.search_button) - editText.setHintTextColor(dynamicColor.onSurfaceVariant().getArgb(scheme)) - editText.highlightColor = dynamicColor.inverseOnSurface().getArgb(scheme) - editText.setTextColor(dynamicColor.onSurface().getArgb(scheme)) - closeButton.setColorFilter(dynamicColor.onSurface().getArgb(scheme)) - searchButton.setColorFilter(dynamicColor.onSurface().getArgb(scheme)) - searchPlate.setBackgroundColor(dynamicColor.surface().getArgb(scheme)) - } + fun themeToolbarSearchView(searchView: SearchView) { + withScheme(searchView) { scheme -> + // hacky as no default way is provided + val editText = + searchView.findViewById(androidx.appcompat.R.id.search_src_text) + val searchPlate = searchView.findViewById(androidx.appcompat.R.id.search_plate) + val closeButton = searchView.findViewById(androidx.appcompat.R.id.search_close_btn) + val searchButton = searchView.findViewById(androidx.appcompat.R.id.search_button) + editText.setHintTextColor(dynamicColor.onSurfaceVariant().getArgb(scheme)) + editText.highlightColor = dynamicColor.inverseOnSurface().getArgb(scheme) + editText.setTextColor(dynamicColor.onSurface().getArgb(scheme)) + closeButton.setColorFilter(dynamicColor.onSurface().getArgb(scheme)) + searchButton.setColorFilter(dynamicColor.onSurface().getArgb(scheme)) + searchPlate.setBackgroundColor(dynamicColor.surface().getArgb(scheme)) } + } - fun themeNotificationCompatBuilder( - context: Context, - builder: NotificationCompat.Builder - ) { - withScheme(context) { scheme -> - builder.setColor(dynamicColor.primary().getArgb(scheme)) - } + fun themeNotificationCompatBuilder(context: Context, builder: NotificationCompat.Builder) { + withScheme(context) { scheme -> + builder.setColor(dynamicColor.primary().getArgb(scheme)) } + } - private fun getColoredSpan( - title: String, - color: Int - ): Spannable { - val text: Spannable = SpannableString(title) - text.setSpan( - ForegroundColorSpan(color), - 0, - text.length, - Spannable.SPAN_INCLUSIVE_INCLUSIVE - ) - return text - } + private fun getColoredSpan(title: String, color: Int): Spannable { + val text: Spannable = SpannableString(title) + text.setSpan( + ForegroundColorSpan(color), + 0, + text.length, + Spannable.SPAN_INCLUSIVE_INCLUSIVE + ) + return text } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/ColorRole.kt b/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/ColorRole.kt index 86a68625..4e67244f 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/ColorRole.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/ColorRole.kt @@ -13,9 +13,7 @@ import dynamiccolor.DynamicScheme /** * Parameter for library methods so that clients can choose color roles without accessing the Scheme directly */ -enum class ColorRole( - internal val select: (DynamicScheme) -> Int -) { +enum class ColorRole(internal val select: (DynamicScheme) -> Int) { PRIMARY({ dynamiccolor.MaterialDynamicColors().primary().getArgb(it) }), ON_PRIMARY({ dynamiccolor.MaterialDynamicColors().onPrimary().getArgb(it) }), PRIMARY_CONTAINER({ dynamiccolor.MaterialDynamicColors().primaryContainer().getArgb(it) }), diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/DialogViewThemeUtils.kt b/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/DialogViewThemeUtils.kt index 477292f3..eba54e8e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/DialogViewThemeUtils.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/DialogViewThemeUtils.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022-2023 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2022-2023 Andy Scherzinger * SPDX-FileCopyrightText: 2022 Álvaro Brey @@ -29,70 +30,62 @@ import javax.inject.Inject * View theme utils for dialogs */ class DialogViewThemeUtils - @Inject - constructor( - schemes: MaterialSchemes - ) : ViewThemeUtilsBase(schemes) { - private val dynamicColor = MaterialDynamicColors() +@Inject +constructor(schemes: MaterialSchemes) : ViewThemeUtilsBase(schemes) { + private val dynamicColor = MaterialDynamicColors() - fun colorMaterialAlertDialogBackground( - context: Context, - dialogBuilder: MaterialAlertDialogBuilder - ) { - withScheme(dialogBuilder.context) { scheme -> - val materialShapeDrawable = - MaterialShapeDrawable( - context, - null, - androidx.appcompat.R.attr.alertDialogStyle, - com.google.android.material.R.style.MaterialAlertDialog_MaterialComponents - ) - materialShapeDrawable.initializeElevationOverlay(context) - materialShapeDrawable.fillColor = ColorStateList.valueOf(dynamicColor.surface().getArgb(scheme)) + fun colorMaterialAlertDialogBackground(context: Context, dialogBuilder: MaterialAlertDialogBuilder) { + withScheme(dialogBuilder.context) { scheme -> + val materialShapeDrawable = + MaterialShapeDrawable( + context, + null, + androidx.appcompat.R.attr.alertDialogStyle, + com.google.android.material.R.style.MaterialAlertDialog_MaterialComponents + ) + materialShapeDrawable.initializeElevationOverlay(context) + materialShapeDrawable.fillColor = ColorStateList.valueOf(dynamicColor.surface().getArgb(scheme)) - // dialogCornerRadius first appeared in Android Pie - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val radius = context.resources.getDimension(R.dimen.dialogBorderRadius) - materialShapeDrawable.setCornerSize(radius) - } - - dialogBuilder.background = materialShapeDrawable + // dialogCornerRadius first appeared in Android Pie + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val radius = context.resources.getDimension(R.dimen.dialogBorderRadius) + materialShapeDrawable.setCornerSize(radius) } + + dialogBuilder.background = materialShapeDrawable } + } - fun colorDialogMenuText(button: MaterialButton) { - withScheme(button) { scheme -> - button.setTextColor(dynamicColor.onSurface().getArgb(scheme)) - button.iconTint = ColorStateList.valueOf(dynamicColor.onSurface().getArgb(scheme)) - } + fun colorDialogMenuText(button: MaterialButton) { + withScheme(button) { scheme -> + button.setTextColor(dynamicColor.onSurface().getArgb(scheme)) + button.iconTint = ColorStateList.valueOf(dynamicColor.onSurface().getArgb(scheme)) } + } - fun colorDialogHeadline(textView: TextView) { - withScheme(textView) { scheme -> - textView.setTextColor(dynamicColor.onSurface().getArgb(scheme)) - } + fun colorDialogHeadline(textView: TextView) { + withScheme(textView) { scheme -> + textView.setTextColor(dynamicColor.onSurface().getArgb(scheme)) } + } - fun colorDialogSupportingText(textView: TextView) { - withScheme(textView) { scheme -> - textView.setTextColor(dynamicColor.onSurfaceVariant().getArgb(scheme)) - } + fun colorDialogSupportingText(textView: TextView) { + withScheme(textView) { scheme -> + textView.setTextColor(dynamicColor.onSurfaceVariant().getArgb(scheme)) } + } - fun colorDialogIcon(icon: ImageView) { - withScheme(icon) { scheme -> - icon.setColorFilter(dynamicColor.secondary().getArgb(scheme)) - } + fun colorDialogIcon(icon: ImageView) { + withScheme(icon) { scheme -> + icon.setColorFilter(dynamicColor.secondary().getArgb(scheme)) } + } - fun colorMaterialAlertDialogIcon( - context: Context, - drawableId: Int - ): Drawable { - val drawable = AppCompatResources.getDrawable(context, drawableId)!! - withScheme(context) { scheme -> - DrawableCompat.setTint(drawable, dynamicColor.secondary().getArgb(scheme)) - } - return drawable + fun colorMaterialAlertDialogIcon(context: Context, drawableId: Int): Drawable { + val drawable = AppCompatResources.getDrawable(context, drawableId)!! + withScheme(context) { scheme -> + DrawableCompat.setTint(drawable, dynamicColor.secondary().getArgb(scheme)) } + return drawable } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/MaterialViewThemeUtils.kt b/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/MaterialViewThemeUtils.kt index b3c2c006..4cc60d4e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/MaterialViewThemeUtils.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/MaterialViewThemeUtils.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022-2023 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2022-2023 Andy Scherzinger * SPDX-FileCopyrightText: 2023 Stefan Niedermann @@ -49,720 +50,684 @@ import javax.inject.Inject */ @Suppress("TooManyFunctions", "LargeClass") class MaterialViewThemeUtils - @Inject - constructor( - schemes: MaterialSchemes, - private val colorUtil: ColorUtil - ) : ViewThemeUtilsBase(schemes) { - private val dynamicColor = MaterialDynamicColors() - - fun colorToolbarOverflowIcon(toolbar: MaterialToolbar) { - withScheme(toolbar) { scheme -> - colorTrailingIcon(scheme, toolbar.overflowIcon) - } - } - - private fun colorTrailingIcon( - scheme: DynamicScheme, - icon: Drawable? - ) { - icon?.colorFilter = - BlendModeColorFilterCompat.createBlendModeColorFilterCompat( - dynamicColor.onSurfaceVariant().getArgb(scheme), - BlendModeCompat.SRC_ATOP - ) - } - - fun themeSearchBarText(searchText: MaterialTextView) { - withScheme(searchText) { scheme -> - searchText.setHintTextColor(dynamicColor.surfaceVariant().getArgb(scheme)) - } - } - - fun themeFAB(fab: FloatingActionButton) { - withScheme(fab) { scheme -> - fab.backgroundTintList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.primaryContainer().getArgb(scheme), - -android.R.attr.state_enabled to Color.GRAY - ) +@Inject +constructor(schemes: MaterialSchemes, private val colorUtil: ColorUtil) : + ViewThemeUtilsBase(schemes) { + private val dynamicColor = MaterialDynamicColors() - fab.imageTintList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.onPrimaryContainer().getArgb(scheme), - -android.R.attr.state_enabled to Color.WHITE - ) - } + fun colorToolbarOverflowIcon(toolbar: MaterialToolbar) { + withScheme(toolbar) { scheme -> + colorTrailingIcon(scheme, toolbar.overflowIcon) } + } - fun themeSecondaryFAB(fab: FloatingActionButton) { - withScheme(fab) { scheme -> - fab.backgroundTintList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.secondaryContainer().getArgb(scheme), - -android.R.attr.state_enabled to Color.GRAY - ) + private fun colorTrailingIcon(scheme: DynamicScheme, icon: Drawable?) { + icon?.colorFilter = + BlendModeColorFilterCompat.createBlendModeColorFilterCompat( + dynamicColor.onSurfaceVariant().getArgb(scheme), + BlendModeCompat.SRC_ATOP + ) + } - fab.imageTintList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.onSecondaryContainer().getArgb(scheme), - -android.R.attr.state_enabled to Color.WHITE - ) - } + fun themeSearchBarText(searchText: MaterialTextView) { + withScheme(searchText) { scheme -> + searchText.setHintTextColor(dynamicColor.surfaceVariant().getArgb(scheme)) } + } - fun themeExtendedFAB(fab: ExtendedFloatingActionButton) { - withScheme(fab) { scheme -> - fab.backgroundTintList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.primaryContainer().getArgb(scheme), - -android.R.attr.state_enabled to Color.GRAY - ) + fun themeFAB(fab: FloatingActionButton) { + withScheme(fab) { scheme -> + fab.backgroundTintList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.primaryContainer().getArgb(scheme), + -android.R.attr.state_enabled to Color.GRAY + ) - val colorStateList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.onPrimaryContainer().getArgb(scheme), - -android.R.attr.state_enabled to Color.WHITE - ) - fab.setTextColor(colorStateList) - fab.iconTint = colorStateList - } - } - - fun themeCardView(cardView: MaterialCardView) { - withScheme(cardView) { scheme -> - cardView.backgroundTintList = ColorStateList.valueOf(dynamicColor.surface().getArgb(scheme)) - cardView.setStrokeColor( - buildColorStateList( - android.R.attr.state_checked to dynamicColor.primary().getArgb(scheme), - -android.R.attr.state_checked to dynamicColor.outlineVariant().getArgb(scheme) - ) + fab.imageTintList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.onPrimaryContainer().getArgb(scheme), + -android.R.attr.state_enabled to Color.WHITE ) - } } + } - fun themeSearchCardView(cardView: MaterialCardView) { - withScheme(cardView) { scheme -> - cardView.backgroundTintList = - ColorStateList.valueOf( - dynamicColor.surfaceContainerHigh().getArgb(scheme) - ) - cardView.setStrokeColor( - buildColorStateList( - android.R.attr.state_checked to dynamicColor.surfaceContainerHigh().getArgb(scheme), - -android.R.attr.state_checked to dynamicColor.surfaceContainerHigh().getArgb(scheme) - ) + fun themeSecondaryFAB(fab: FloatingActionButton) { + withScheme(fab) { scheme -> + fab.backgroundTintList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.secondaryContainer().getArgb(scheme), + -android.R.attr.state_enabled to Color.GRAY ) - } - } - fun themeDragHandleView(dragHandleView: BottomSheetDragHandleView) { - withScheme(dragHandleView) { scheme -> - dragHandleView.imageTintList = ColorStateList.valueOf(dynamicColor.onSurfaceVariant().getArgb(scheme)) - } + fab.imageTintList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.onSecondaryContainer().getArgb(scheme), + -android.R.attr.state_enabled to Color.WHITE + ) } + } + + fun themeExtendedFAB(fab: ExtendedFloatingActionButton) { + withScheme(fab) { scheme -> + fab.backgroundTintList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.primaryContainer().getArgb(scheme), + -android.R.attr.state_enabled to Color.GRAY + ) - fun colorMaterialTextButton(button: MaterialButton) { - withScheme(button) { scheme -> - button.rippleColor = rippleColor(scheme) - } + val colorStateList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.onPrimaryContainer().getArgb(scheme), + -android.R.attr.state_enabled to Color.WHITE + ) + fab.setTextColor(colorStateList) + fab.iconTint = colorStateList } + } - fun colorMaterialButtonText(button: MaterialButton) { - withScheme(button) { scheme -> - val disabledColor = ContextCompat.getColor(button.context, R.color.disabled_text) - val colorStateList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.primary().getArgb(scheme), - -android.R.attr.state_enabled to disabledColor - ) - button.setTextColor(colorStateList) - button.iconTint = colorStateList - } - } - - fun colorMaterialButtonPrimaryFilled(button: MaterialButton) { - withScheme(button) { scheme -> - button.backgroundTintList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.primary().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - SURFACE_OPACITY_BUTTON_DISABLED - ) - ) + fun themeCardView(cardView: MaterialCardView) { + withScheme(cardView) { scheme -> + cardView.backgroundTintList = ColorStateList.valueOf(dynamicColor.surface().getArgb(scheme)) + cardView.setStrokeColor( + buildColorStateList( + android.R.attr.state_checked to dynamicColor.primary().getArgb(scheme), + -android.R.attr.state_checked to dynamicColor.outlineVariant().getArgb(scheme) + ) + ) + } + } - val contentColorList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.onPrimary().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - SURFACE_OPACITY_BUTTON_DISABLED - ) - ) + fun themeSearchCardView(cardView: MaterialCardView) { + withScheme(cardView) { scheme -> + cardView.backgroundTintList = + ColorStateList.valueOf( + dynamicColor.surfaceContainerHigh().getArgb(scheme) + ) + cardView.setStrokeColor( + buildColorStateList( + android.R.attr.state_checked to dynamicColor.surfaceContainerHigh().getArgb(scheme), + -android.R.attr.state_checked to dynamicColor.surfaceContainerHigh().getArgb(scheme) + ) + ) + } + } - button.setTextColor(contentColorList) - button.iconTint = contentColorList - } - } - - fun colorMaterialButtonPrimaryTonal(button: MaterialButton) { - withScheme(button) { scheme -> - button.backgroundTintList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.secondaryContainer().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - SURFACE_OPACITY_BUTTON_DISABLED - ), - -android.R.attr.state_hovered to dynamicColor.onSecondaryContainer().getArgb(scheme), - -android.R.attr.state_focused to dynamicColor.onSecondaryContainer().getArgb(scheme), - -android.R.attr.state_pressed to dynamicColor.onSecondaryContainer().getArgb(scheme) - ) + fun themeDragHandleView(dragHandleView: BottomSheetDragHandleView) { + withScheme(dragHandleView) { scheme -> + dragHandleView.imageTintList = ColorStateList.valueOf(dynamicColor.onSurfaceVariant().getArgb(scheme)) + } + } - val contentColorList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.onSecondaryContainer().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - ON_SURFACE_OPACITY_BUTTON_DISABLED - ), - -android.R.attr.state_hovered to dynamicColor.onSecondaryContainer().getArgb(scheme), - -android.R.attr.state_focused to dynamicColor.onSecondaryContainer().getArgb(scheme), - -android.R.attr.state_pressed to dynamicColor.onSecondaryContainer().getArgb(scheme) - ) - button.setTextColor(contentColorList) - button.iconTint = contentColorList - } - } - - fun colorMaterialButtonPrimaryOutlined(button: MaterialButton) { - withScheme(button) { scheme -> - val contentColorList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.primary().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - ON_SURFACE_OPACITY_BUTTON_DISABLED - ) - ) + fun colorMaterialTextButton(button: MaterialButton) { + withScheme(button) { scheme -> + button.rippleColor = rippleColor(scheme) + } + } - button.setTextColor(contentColorList) - button.iconTint = contentColorList - button.strokeColor = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.outline().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - ON_SURFACE_OPACITY_BUTTON_OUTLINE_DISABLED - ), - -android.R.attr.state_hovered to dynamicColor.outline().getArgb(scheme), - -android.R.attr.state_focused to dynamicColor.primary().getArgb(scheme), - -android.R.attr.state_pressed to dynamicColor.outline().getArgb(scheme) - ) + fun colorMaterialButtonText(button: MaterialButton) { + withScheme(button) { scheme -> + val disabledColor = ContextCompat.getColor(button.context, R.color.disabled_text) + val colorStateList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.primary().getArgb(scheme), + -android.R.attr.state_enabled to disabledColor + ) + button.setTextColor(colorStateList) + button.iconTint = colorStateList + } + } - button.strokeWidth = - button.resources.getDimension(R.dimen.outlinedButtonStrokeWidth).toInt() - button.rippleColor = rippleColor(scheme) - } - } - - fun colorMaterialButtonPrimaryBorderless(button: MaterialButton) { - withScheme(button) { scheme -> - val contentColorList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.primary().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - ON_SURFACE_OPACITY_BUTTON_DISABLED - ) - ) + fun colorMaterialButtonPrimaryFilled(button: MaterialButton) { + withScheme(button) { scheme -> + button.backgroundTintList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.primary().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + SURFACE_OPACITY_BUTTON_DISABLED + ) + ) - button.setTextColor(contentColorList) - button.iconTint = contentColorList - } - } - - /** - * text is primary, background is on_primary - */ - fun colorMaterialButtonFilledOnPrimary(button: MaterialButton) { - withScheme(button) { scheme -> - button.backgroundTintList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.onPrimary().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.surface().getArgb(scheme), - SURFACE_OPACITY_BUTTON_DISABLED - ), - -android.R.attr.state_hovered to dynamicColor.onPrimary().getArgb(scheme), - -android.R.attr.state_focused to dynamicColor.onPrimary().getArgb(scheme), - -android.R.attr.state_pressed to dynamicColor.onPrimary().getArgb(scheme) - ) + val contentColorList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.onPrimary().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + SURFACE_OPACITY_BUTTON_DISABLED + ) + ) - colorMaterialButtonContent(button) - } - } - - fun colorMaterialButtonContent( - button: MaterialButton, - colorRole: ColorRole = ColorRole.PRIMARY - ) { - withScheme(button) { scheme -> - val contentColorList = - buildColorStateList( - android.R.attr.state_enabled to colorRole.select(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - colorRole.select(scheme), - ON_SURFACE_OPACITY_BUTTON_DISABLED - ), - -android.R.attr.state_hovered to colorRole.select(scheme), - -android.R.attr.state_focused to colorRole.select(scheme), - -android.R.attr.state_pressed to colorRole.select(scheme) - ) + button.setTextColor(contentColorList) + button.iconTint = contentColorList + } + } - button.setTextColor( - contentColorList + fun colorMaterialButtonPrimaryTonal(button: MaterialButton) { + withScheme(button) { scheme -> + button.backgroundTintList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.secondaryContainer().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + SURFACE_OPACITY_BUTTON_DISABLED + ), + -android.R.attr.state_hovered to dynamicColor.onSecondaryContainer().getArgb(scheme), + -android.R.attr.state_focused to dynamicColor.onSecondaryContainer().getArgb(scheme), + -android.R.attr.state_pressed to dynamicColor.onSecondaryContainer().getArgb(scheme) ) - button.iconTint = contentColorList - } + val contentColorList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.onSecondaryContainer().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + ON_SURFACE_OPACITY_BUTTON_DISABLED + ), + -android.R.attr.state_hovered to dynamicColor.onSecondaryContainer().getArgb(scheme), + -android.R.attr.state_focused to dynamicColor.onSecondaryContainer().getArgb(scheme), + -android.R.attr.state_pressed to dynamicColor.onSecondaryContainer().getArgb(scheme) + ) + button.setTextColor(contentColorList) + button.iconTint = contentColorList } + } - fun colorMaterialButtonOutlinedOnPrimary(button: MaterialButton) { - withScheme(button) { scheme -> - button.backgroundTintList = ColorStateList.valueOf(Color.TRANSPARENT) - val contentColorList = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.onPrimary().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onPrimary().getArgb(scheme), - ON_SURFACE_OPACITY_BUTTON_DISABLED - ) - ) + fun colorMaterialButtonPrimaryOutlined(button: MaterialButton) { + withScheme(button) { scheme -> + val contentColorList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.primary().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + ON_SURFACE_OPACITY_BUTTON_DISABLED + ) + ) - button.setTextColor(contentColorList) - button.iconTint = contentColorList - button.strokeColor = contentColorList - button.strokeWidth = - button.resources.getDimension(R.dimen.outlinedButtonStrokeWidth).toInt() - button.rippleColor = rippleColor(scheme) - } - } - - fun themeToolbar(toolbar: MaterialToolbar) { - withScheme(toolbar) { scheme -> - toolbar.setBackgroundColor(dynamicColor.surface().getArgb(scheme)) - toolbar.setNavigationIconTint(dynamicColor.onSurface().getArgb(scheme)) - toolbar.setTitleTextColor(dynamicColor.onSurface().getArgb(scheme)) - colorTrailingIcon(scheme, toolbar.overflowIcon) - } - } - - @Deprecated( - "Duplicated, use themeCardView instead", - replaceWith = ReplaceWith("themeCardView(card)") - ) - fun colorCardViewBackground(card: MaterialCardView) = themeCardView(card) - - /** - * Themes a [LinearProgressIndicator] using the provided [ColorRole]. - * - * @param linearProgressIndicator The progress indicator to theme. - * @param colorRole The color role to be used for the active indicator part. Defaults to [ColorRole.PRIMARY]. - */ - fun colorProgressBar( - linearProgressIndicator: LinearProgressIndicator, - colorRole: ColorRole = ColorRole.PRIMARY - ) { - withScheme(linearProgressIndicator) { scheme -> - colorProgressBar(linearProgressIndicator, colorRole.select(scheme)) - } - } - - /** - * Themes a [LinearProgressIndicator] using the provided color [Int]. - * - * @param linearProgressIndicator The progress indicator to theme. - * @param color The color to be used for the active indicator part. - */ - fun colorProgressBar( - linearProgressIndicator: LinearProgressIndicator, - @ColorInt color: Int - ) { - withScheme(linearProgressIndicator) { scheme -> - linearProgressIndicator.setIndicatorColor(color) - linearProgressIndicator.trackColor = dynamicColor.secondaryContainer().getArgb(scheme) - } - } - - /** - * Themes a [CircularProgressIndicator] using the provided [ColorRole]. - * - * @param circularProgressIndicator The progress indicator to theme. - * @param colorRole The color role to be used for the active indicator part. Defaults to [ColorRole.PRIMARY]. - */ - fun colorProgressBar( - circularProgressIndicator: CircularProgressIndicator, - colorRole: ColorRole = ColorRole.PRIMARY - ) { - withScheme(circularProgressIndicator) { scheme -> - colorProgressBar(circularProgressIndicator, colorRole.select(scheme)) - } - } - - /** - * Themes a [CircularProgressIndicator] using the provided color [Int]. - * - * @param circularProgressIndicator The progress indicator to theme. - * @param color The color to be used for the active indicator part. - */ - fun colorProgressBar( - circularProgressIndicator: CircularProgressIndicator, - @ColorInt color: Int - ) { - withScheme(circularProgressIndicator) { scheme -> - circularProgressIndicator.setIndicatorColor(color) - circularProgressIndicator.trackColor = dynamicColor.secondaryContainer().getArgb(scheme) - } - } - - fun colorTextInputLayout(textInputLayout: TextInputLayout) { - withScheme(textInputLayout) { scheme -> - val errorColor = dynamicColor.error().getArgb(scheme) - - val errorColorStateList = - buildColorStateList( - -android.R.attr.state_focused to errorColor, - android.R.attr.state_focused to errorColor - ) + button.setTextColor(contentColorList) + button.iconTint = contentColorList + button.strokeColor = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.outline().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + ON_SURFACE_OPACITY_BUTTON_OUTLINE_DISABLED + ), + -android.R.attr.state_hovered to dynamicColor.outline().getArgb(scheme), + -android.R.attr.state_focused to dynamicColor.primary().getArgb(scheme), + -android.R.attr.state_pressed to dynamicColor.outline().getArgb(scheme) + ) - val coloredColorStateList = - buildColorStateList( - -android.R.attr.state_focused to dynamicColor.outline().getArgb(scheme), - android.R.attr.state_focused to dynamicColor.primary().getArgb(scheme) - ) + button.strokeWidth = + button.resources.getDimension(R.dimen.outlinedButtonStrokeWidth).toInt() + button.rippleColor = rippleColor(scheme) + } + } - textInputLayout.setBoxStrokeColorStateList(coloredColorStateList) - textInputLayout.setErrorIconTintList(errorColorStateList) - textInputLayout.setErrorTextColor(errorColorStateList) - textInputLayout.boxStrokeErrorColor = errorColorStateList - textInputLayout.defaultHintTextColor = coloredColorStateList + fun colorMaterialButtonPrimaryBorderless(button: MaterialButton) { + withScheme(button) { scheme -> + val contentColorList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.primary().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + ON_SURFACE_OPACITY_BUTTON_DISABLED + ) + ) - textInputLayout.editText?.highlightColor = dynamicColor.primary().getArgb(scheme) - } + button.setTextColor(contentColorList) + button.iconTint = contentColorList } + } - fun colorTextInputLayout( - textInputLayout: TextInputLayout, - colorRole: ColorRole - ) { - withScheme(textInputLayout) { scheme -> - val errorColor = dynamicColor.error().getArgb(scheme) + /** + * text is primary, background is on_primary + */ + fun colorMaterialButtonFilledOnPrimary(button: MaterialButton) { + withScheme(button) { scheme -> + button.backgroundTintList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.onPrimary().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.surface().getArgb(scheme), + SURFACE_OPACITY_BUTTON_DISABLED + ), + -android.R.attr.state_hovered to dynamicColor.onPrimary().getArgb(scheme), + -android.R.attr.state_focused to dynamicColor.onPrimary().getArgb(scheme), + -android.R.attr.state_pressed to dynamicColor.onPrimary().getArgb(scheme) + ) - val errorColorStateList = - buildColorStateList( - -android.R.attr.state_focused to errorColor, - android.R.attr.state_focused to errorColor - ) + colorMaterialButtonContent(button) + } + } - val coloredColorStateList = - buildColorStateList( - -android.R.attr.state_focused to dynamicColor.outline().getArgb(scheme), - android.R.attr.state_focused to colorRole.select(scheme) - ) + fun colorMaterialButtonContent(button: MaterialButton, colorRole: ColorRole = ColorRole.PRIMARY) { + withScheme(button) { scheme -> + val contentColorList = + buildColorStateList( + android.R.attr.state_enabled to colorRole.select(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + colorRole.select(scheme), + ON_SURFACE_OPACITY_BUTTON_DISABLED + ), + -android.R.attr.state_hovered to colorRole.select(scheme), + -android.R.attr.state_focused to colorRole.select(scheme), + -android.R.attr.state_pressed to colorRole.select(scheme) + ) + + button.setTextColor( + contentColorList + ) - textInputLayout.setBoxStrokeColorStateList(coloredColorStateList) - textInputLayout.setErrorIconTintList(errorColorStateList) - textInputLayout.setErrorTextColor(errorColorStateList) - textInputLayout.boxStrokeErrorColor = errorColorStateList - textInputLayout.defaultHintTextColor = coloredColorStateList - - textInputLayout.editText?.highlightColor = colorRole.select(scheme) - textInputLayout.setEndIconTintList(coloredColorStateList) - textInputLayout.setStartIconTintList(coloredColorStateList) - } - } - - fun themeTabLayout(tabLayout: TabLayout) { - withScheme(tabLayout) { scheme -> - colorTabLayout(tabLayout, scheme) - } - } - - fun themeTabLayoutOnSurface(tabLayout: TabLayout) { - withScheme(tabLayout) { scheme -> - tabLayout.setBackgroundColor(dynamicColor.surface().getArgb(scheme)) - colorTabLayout(tabLayout, scheme) - } - } - - fun colorBottomSheetBackground( - bottomSheet: View, - colorRole: ColorRole = ColorRole.SURFACE_CONTAINER_LOW - ) { - withScheme(bottomSheet) { scheme -> - bottomSheet.setBackgroundColor(colorRole.select(scheme)) - } - } - - fun colorBottomSheetDragHandle( - bottomSheetDragHandleView: BottomSheetDragHandleView, - colorRole: ColorRole = ColorRole.ON_SURFACE_VARIANT - ) { - withScheme(bottomSheetDragHandleView) { scheme -> - bottomSheetDragHandleView.setColorFilter(colorRole.select(scheme)) - } - } - - fun colorTabLayout( - tabLayout: TabLayout, - scheme: DynamicScheme - ) { - tabLayout.setSelectedTabIndicatorColor(dynamicColor.primary().getArgb(scheme)) - val tabContentColors = - buildColorStateList( - android.R.attr.state_selected to dynamicColor.primary().getArgb(scheme), - -android.R.attr.state_selected to - ContextCompat.getColor( - tabLayout.context, - R.color.high_emphasis_text + button.iconTint = contentColorList + } + } + + fun colorMaterialButtonOutlinedOnPrimary(button: MaterialButton) { + withScheme(button) { scheme -> + button.backgroundTintList = ColorStateList.valueOf(Color.TRANSPARENT) + val contentColorList = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.onPrimary().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onPrimary().getArgb(scheme), + ON_SURFACE_OPACITY_BUTTON_DISABLED ) ) - tabLayout.tabTextColors = tabContentColors - tabLayout.tabIconTint = tabContentColors - tabLayout.tabRippleColor = rippleColor(scheme) + button.setTextColor(contentColorList) + button.iconTint = contentColorList + button.strokeColor = contentColorList + button.strokeWidth = + button.resources.getDimension(R.dimen.outlinedButtonStrokeWidth).toInt() + button.rippleColor = rippleColor(scheme) } + } - fun colorMaterialCheckBox(materialCheckBox: MaterialCheckBox) { - withScheme(materialCheckBox.context) { scheme -> - materialCheckBox.buttonTintList = - buildColorStateList( - android.R.attr.state_checked to dynamicColor.primary().getArgb(scheme), - -android.R.attr.state_checked to dynamicColor.outline().getArgb(scheme) - ) + fun themeToolbar(toolbar: MaterialToolbar) { + withScheme(toolbar) { scheme -> + toolbar.setBackgroundColor(dynamicColor.surface().getArgb(scheme)) + toolbar.setNavigationIconTint(dynamicColor.onSurface().getArgb(scheme)) + toolbar.setTitleTextColor(dynamicColor.onSurface().getArgb(scheme)) + colorTrailingIcon(scheme, toolbar.overflowIcon) + } + } - materialCheckBox.buttonIconTintList = - buildColorStateList( - android.R.attr.state_checked to dynamicColor.onPrimary().getArgb(scheme), - -android.R.attr.state_checked to android.R.color.transparent - ) - } + @Deprecated( + "Duplicated, use themeCardView instead", + replaceWith = ReplaceWith("themeCardView(card)") + ) + fun colorCardViewBackground(card: MaterialCardView) = themeCardView(card) + + /** + * Themes a [LinearProgressIndicator] using the provided [ColorRole]. + * + * @param linearProgressIndicator The progress indicator to theme. + * @param colorRole The color role to be used for the active indicator part. Defaults to [ColorRole.PRIMARY]. + */ + fun colorProgressBar(linearProgressIndicator: LinearProgressIndicator, colorRole: ColorRole = ColorRole.PRIMARY) { + withScheme(linearProgressIndicator) { scheme -> + colorProgressBar(linearProgressIndicator, colorRole.select(scheme)) } + } - fun colorMaterialSwitch(materialSwitch: MaterialSwitch) { - withScheme(materialSwitch.context) { scheme -> - materialSwitch.thumbTintList = - buildColorStateList( - android.R.attr.state_checked to dynamicColor.onPrimary().getArgb(scheme), - -android.R.attr.state_checked to dynamicColor.outline().getArgb(scheme) - ) + /** + * Themes a [LinearProgressIndicator] using the provided color [Int]. + * + * @param linearProgressIndicator The progress indicator to theme. + * @param color The color to be used for the active indicator part. + */ + fun colorProgressBar(linearProgressIndicator: LinearProgressIndicator, @ColorInt color: Int) { + withScheme(linearProgressIndicator) { scheme -> + linearProgressIndicator.setIndicatorColor(color) + linearProgressIndicator.trackColor = dynamicColor.secondaryContainer().getArgb(scheme) + } + } - materialSwitch.trackTintList = - buildColorStateList( - android.R.attr.state_checked to dynamicColor.primary().getArgb(scheme), - // XXX: specs use surfaceContainerHighest - -android.R.attr.state_checked to dynamicColor.surface().getArgb(scheme) - ) + /** + * Themes a [CircularProgressIndicator] using the provided [ColorRole]. + * + * @param circularProgressIndicator The progress indicator to theme. + * @param colorRole The color role to be used for the active indicator part. Defaults to [ColorRole.PRIMARY]. + */ + fun colorProgressBar( + circularProgressIndicator: CircularProgressIndicator, + colorRole: ColorRole = ColorRole.PRIMARY + ) { + withScheme(circularProgressIndicator) { scheme -> + colorProgressBar(circularProgressIndicator, colorRole.select(scheme)) + } + } - materialSwitch.trackDecorationTintList = - buildColorStateList( - android.R.attr.state_checked to android.R.color.transparent, - -android.R.attr.state_checked to dynamicColor.outline().getArgb(scheme) - ) - } - } - - fun colorChipBackground(chip: Chip) { - withScheme(chip) { scheme -> - chip.chipBackgroundColor = ColorStateList.valueOf(dynamicColor.primary().getArgb(scheme)) - chip.setTextColor(dynamicColor.onPrimary().getArgb(scheme)) - } - } - - fun colorChipDrawable( - context: Context, - chipDrawable: ChipDrawable - ) { - withScheme(context) { scheme -> - chipDrawable.chipBackgroundColor = ColorStateList.valueOf(dynamicColor.primary().getArgb(scheme)) - chipDrawable.setTextColor(dynamicColor.onPrimary().getArgb(scheme)) - } - } - - fun colorChipOutlined( - chip: Chip, - strokeWidth: Float - ) { - withScheme(chip) { scheme -> - chip.chipBackgroundColor = ColorStateList.valueOf(Color.TRANSPARENT) - chip.chipStrokeWidth = strokeWidth - chip.chipStrokeColor = ColorStateList.valueOf(dynamicColor.primary().getArgb(scheme)) - chip.setTextColor(dynamicColor.primary().getArgb(scheme)) - } - } - - fun themeSnackbar(snackbar: Snackbar) { - withScheme(snackbar.context) { scheme -> - snackbar.setBackgroundTint(dynamicColor.inverseSurface().getArgb(scheme)) - snackbar.setActionTextColor(dynamicColor.inversePrimary().getArgb(scheme)) - snackbar.setTextColor(dynamicColor.inverseOnSurface().getArgb(scheme)) - } - } - - fun themeChipSuggestion(chip: Chip) { - withScheme(chip.context) { scheme -> - chip.chipStrokeColor = chipOutlineColorList(scheme) - chip.setTextColor(chipSuggestionInputTextColorList(scheme)) - } - } - - fun themeChipFilter(chip: Chip) { - withScheme(chip.context) { scheme -> - val backgroundColors = - buildColorStateList( - android.R.attr.state_checked to dynamicColor.secondaryContainer().getArgb(scheme), - -android.R.attr.state_checked to dynamicColor.surface().getArgb(scheme), - android.R.attr.state_focused to dynamicColor.secondaryContainer().getArgb(scheme), - android.R.attr.state_hovered to dynamicColor.secondaryContainer().getArgb(scheme), - android.R.attr.state_pressed to dynamicColor.secondaryContainer().getArgb(scheme) - ) + /** + * Themes a [CircularProgressIndicator] using the provided color [Int]. + * + * @param circularProgressIndicator The progress indicator to theme. + * @param color The color to be used for the active indicator part. + */ + fun colorProgressBar(circularProgressIndicator: CircularProgressIndicator, @ColorInt color: Int) { + withScheme(circularProgressIndicator) { scheme -> + circularProgressIndicator.setIndicatorColor(color) + circularProgressIndicator.trackColor = dynamicColor.secondaryContainer().getArgb(scheme) + } + } - val iconColors = - buildColorStateList( - android.R.attr.state_checked to dynamicColor.onSecondaryContainer().getArgb(scheme), - -android.R.attr.state_checked to dynamicColor.onSurfaceVariant().getArgb(scheme), - android.R.attr.state_focused to dynamicColor.onSecondaryContainer().getArgb(scheme), - android.R.attr.state_hovered to dynamicColor.onSecondaryContainer().getArgb(scheme), - android.R.attr.state_pressed to dynamicColor.onSecondaryContainer().getArgb(scheme) - ) + fun colorTextInputLayout(textInputLayout: TextInputLayout) { + withScheme(textInputLayout) { scheme -> + val errorColor = dynamicColor.error().getArgb(scheme) - val textColors = - buildColorStateList( - android.R.attr.state_checked to dynamicColor.onSecondaryContainer().getArgb(scheme), - -android.R.attr.state_checked to dynamicColor.onSurfaceVariant().getArgb(scheme), - android.R.attr.state_hovered to dynamicColor.onSecondaryContainer().getArgb(scheme), - android.R.attr.state_focused to dynamicColor.onSecondaryContainer().getArgb(scheme), - android.R.attr.state_pressed to dynamicColor.onSecondaryContainer().getArgb(scheme) - ) + val errorColorStateList = + buildColorStateList( + -android.R.attr.state_focused to errorColor, + android.R.attr.state_focused to errorColor + ) - chip.chipBackgroundColor = backgroundColors - chip.chipStrokeColor = chipOutlineFilterColorList(scheme) - chip.setTextColor(textColors) - chip.checkedIconTint = iconColors - } - } - - fun themeChipAssist(chip: Chip) { - withScheme(chip.context) { scheme -> - val iconColors = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.primary().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - ON_SURFACE_OPACITY_BUTTON_DISABLED - ), - android.R.attr.state_focused to dynamicColor.primary().getArgb(scheme), - android.R.attr.state_hovered to dynamicColor.primary().getArgb(scheme), - android.R.attr.state_pressed to dynamicColor.primary().getArgb(scheme) - ) + val coloredColorStateList = + buildColorStateList( + -android.R.attr.state_focused to dynamicColor.outline().getArgb(scheme), + android.R.attr.state_focused to dynamicColor.primary().getArgb(scheme) + ) - val textColors = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.onSurface().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - ON_SURFACE_OPACITY_BUTTON_DISABLED - ), - android.R.attr.state_hovered to dynamicColor.onSurface().getArgb(scheme), - android.R.attr.state_focused to dynamicColor.onSurface().getArgb(scheme), - android.R.attr.state_pressed to dynamicColor.onSurface().getArgb(scheme) - ) + textInputLayout.setBoxStrokeColorStateList(coloredColorStateList) + textInputLayout.setErrorIconTintList(errorColorStateList) + textInputLayout.setErrorTextColor(errorColorStateList) + textInputLayout.boxStrokeErrorColor = errorColorStateList + textInputLayout.defaultHintTextColor = coloredColorStateList - chip.chipStrokeColor = chipOutlineColorList(scheme) - chip.setTextColor(textColors) - chip.chipIconTint = iconColors - } - } - - fun themeChipInput(chip: Chip) { - withScheme(chip.context) { scheme -> - val iconColors = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.onSurfaceVariant().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - ON_SURFACE_OPACITY_BUTTON_DISABLED - ), - android.R.attr.state_focused to dynamicColor.onSurfaceVariant().getArgb(scheme), - android.R.attr.state_hovered to dynamicColor.onSurfaceVariant().getArgb(scheme), - android.R.attr.state_pressed to dynamicColor.onSurfaceVariant().getArgb(scheme) - ) + textInputLayout.editText?.highlightColor = dynamicColor.primary().getArgb(scheme) + } + } - chip.chipStrokeColor = chipOutlineFilterColorList(scheme) - chip.setTextColor(chipSuggestionInputTextColorList(scheme)) - chip.chipIconTint = iconColors - } + fun colorTextInputLayout(textInputLayout: TextInputLayout, colorRole: ColorRole) { + withScheme(textInputLayout) { scheme -> + val errorColor = dynamicColor.error().getArgb(scheme) + + val errorColorStateList = + buildColorStateList( + -android.R.attr.state_focused to errorColor, + android.R.attr.state_focused to errorColor + ) + + val coloredColorStateList = + buildColorStateList( + -android.R.attr.state_focused to dynamicColor.outline().getArgb(scheme), + android.R.attr.state_focused to colorRole.select(scheme) + ) + + textInputLayout.setBoxStrokeColorStateList(coloredColorStateList) + textInputLayout.setErrorIconTintList(errorColorStateList) + textInputLayout.setErrorTextColor(errorColorStateList) + textInputLayout.boxStrokeErrorColor = errorColorStateList + textInputLayout.defaultHintTextColor = coloredColorStateList + + textInputLayout.editText?.highlightColor = colorRole.select(scheme) + textInputLayout.setEndIconTintList(coloredColorStateList) + textInputLayout.setStartIconTintList(coloredColorStateList) } + } - private fun chipOutlineColorList(scheme: DynamicScheme) = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.outlineVariant().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - ON_SURFACE_OPACITY_BUTTON_OUTLINE_DISABLED - ), - android.R.attr.state_hovered to dynamicColor.outlineVariant().getArgb(scheme), - android.R.attr.state_focused to dynamicColor.onSurfaceVariant().getArgb(scheme), - android.R.attr.state_pressed to dynamicColor.outlineVariant().getArgb(scheme) - ) + fun themeTabLayout(tabLayout: TabLayout) { + withScheme(tabLayout) { scheme -> + colorTabLayout(tabLayout, scheme) + } + } - private fun chipOutlineFilterColorList(scheme: DynamicScheme) = - buildColorStateList( - android.R.attr.state_checked to dynamicColor.secondaryContainer().getArgb(scheme), - -android.R.attr.state_checked to dynamicColor.outlineVariant().getArgb(scheme) - ) + fun themeTabLayoutOnSurface(tabLayout: TabLayout) { + withScheme(tabLayout) { scheme -> + tabLayout.setBackgroundColor(dynamicColor.surface().getArgb(scheme)) + colorTabLayout(tabLayout, scheme) + } + } - private fun chipSuggestionInputTextColorList(scheme: DynamicScheme) = - buildColorStateList( - android.R.attr.state_enabled to dynamicColor.onSurfaceVariant().getArgb(scheme), - -android.R.attr.state_enabled to - colorUtil.adjustOpacity( - dynamicColor.onSurface().getArgb(scheme), - ON_SURFACE_OPACITY_BUTTON_DISABLED - ), - android.R.attr.state_hovered to dynamicColor.onSurfaceVariant().getArgb(scheme), - android.R.attr.state_focused to dynamicColor.onSurfaceVariant().getArgb(scheme), - android.R.attr.state_pressed to dynamicColor.onSurfaceVariant().getArgb(scheme) - ) + fun colorBottomSheetBackground(bottomSheet: View, colorRole: ColorRole = ColorRole.SURFACE_CONTAINER_LOW) { + withScheme(bottomSheet) { scheme -> + bottomSheet.setBackgroundColor(colorRole.select(scheme)) + } + } + + fun colorBottomSheetDragHandle( + bottomSheetDragHandleView: BottomSheetDragHandleView, + colorRole: ColorRole = ColorRole.ON_SURFACE_VARIANT + ) { + withScheme(bottomSheetDragHandleView) { scheme -> + bottomSheetDragHandleView.setColorFilter(colorRole.select(scheme)) + } + } - private fun rippleColor(scheme: DynamicScheme) = + fun colorTabLayout(tabLayout: TabLayout, scheme: DynamicScheme) { + tabLayout.setSelectedTabIndicatorColor(dynamicColor.primary().getArgb(scheme)) + val tabContentColors = buildColorStateList( - android.R.attr.state_pressed to - colorUtil.adjustOpacity( - dynamicColor.primary().getArgb(scheme), - SURFACE_OPACITY_BUTTON_DISABLED + android.R.attr.state_selected to dynamicColor.primary().getArgb(scheme), + -android.R.attr.state_selected to + ContextCompat.getColor( + tabLayout.context, + R.color.high_emphasis_text ) ) - companion object { - private const val SURFACE_OPACITY_BUTTON_DISABLED: Float = 0.12f - private const val ON_SURFACE_OPACITY_BUTTON_OUTLINE_DISABLED: Float = 0.12f - private const val ON_SURFACE_OPACITY_BUTTON_DISABLED: Float = 0.38f + tabLayout.tabTextColors = tabContentColors + tabLayout.tabIconTint = tabContentColors + tabLayout.tabRippleColor = rippleColor(scheme) + } + + fun colorMaterialCheckBox(materialCheckBox: MaterialCheckBox) { + withScheme(materialCheckBox.context) { scheme -> + materialCheckBox.buttonTintList = + buildColorStateList( + android.R.attr.state_checked to dynamicColor.primary().getArgb(scheme), + -android.R.attr.state_checked to dynamicColor.outline().getArgb(scheme) + ) + + materialCheckBox.buttonIconTintList = + buildColorStateList( + android.R.attr.state_checked to dynamicColor.onPrimary().getArgb(scheme), + -android.R.attr.state_checked to android.R.color.transparent + ) + } + } + + fun colorMaterialSwitch(materialSwitch: MaterialSwitch) { + withScheme(materialSwitch.context) { scheme -> + materialSwitch.thumbTintList = + buildColorStateList( + android.R.attr.state_checked to dynamicColor.onPrimary().getArgb(scheme), + -android.R.attr.state_checked to dynamicColor.outline().getArgb(scheme) + ) + + materialSwitch.trackTintList = + buildColorStateList( + android.R.attr.state_checked to dynamicColor.primary().getArgb(scheme), + // XXX: specs use surfaceContainerHighest + -android.R.attr.state_checked to dynamicColor.surface().getArgb(scheme) + ) + + materialSwitch.trackDecorationTintList = + buildColorStateList( + android.R.attr.state_checked to android.R.color.transparent, + -android.R.attr.state_checked to dynamicColor.outline().getArgb(scheme) + ) + } + } + + fun colorChipBackground(chip: Chip) { + withScheme(chip) { scheme -> + chip.chipBackgroundColor = ColorStateList.valueOf(dynamicColor.primary().getArgb(scheme)) + chip.setTextColor(dynamicColor.onPrimary().getArgb(scheme)) } } + + fun colorChipDrawable(context: Context, chipDrawable: ChipDrawable) { + withScheme(context) { scheme -> + chipDrawable.chipBackgroundColor = ColorStateList.valueOf(dynamicColor.primary().getArgb(scheme)) + chipDrawable.setTextColor(dynamicColor.onPrimary().getArgb(scheme)) + } + } + + fun colorChipOutlined(chip: Chip, strokeWidth: Float) { + withScheme(chip) { scheme -> + chip.chipBackgroundColor = ColorStateList.valueOf(Color.TRANSPARENT) + chip.chipStrokeWidth = strokeWidth + chip.chipStrokeColor = ColorStateList.valueOf(dynamicColor.primary().getArgb(scheme)) + chip.setTextColor(dynamicColor.primary().getArgb(scheme)) + } + } + + fun themeSnackbar(snackbar: Snackbar) { + withScheme(snackbar.context) { scheme -> + snackbar.setBackgroundTint(dynamicColor.inverseSurface().getArgb(scheme)) + snackbar.setActionTextColor(dynamicColor.inversePrimary().getArgb(scheme)) + snackbar.setTextColor(dynamicColor.inverseOnSurface().getArgb(scheme)) + } + } + + fun themeChipSuggestion(chip: Chip) { + withScheme(chip.context) { scheme -> + chip.chipStrokeColor = chipOutlineColorList(scheme) + chip.setTextColor(chipSuggestionInputTextColorList(scheme)) + } + } + + fun themeChipFilter(chip: Chip) { + withScheme(chip.context) { scheme -> + val backgroundColors = + buildColorStateList( + android.R.attr.state_checked to dynamicColor.secondaryContainer().getArgb(scheme), + -android.R.attr.state_checked to dynamicColor.surface().getArgb(scheme), + android.R.attr.state_focused to dynamicColor.secondaryContainer().getArgb(scheme), + android.R.attr.state_hovered to dynamicColor.secondaryContainer().getArgb(scheme), + android.R.attr.state_pressed to dynamicColor.secondaryContainer().getArgb(scheme) + ) + + val iconColors = + buildColorStateList( + android.R.attr.state_checked to dynamicColor.onSecondaryContainer().getArgb(scheme), + -android.R.attr.state_checked to dynamicColor.onSurfaceVariant().getArgb(scheme), + android.R.attr.state_focused to dynamicColor.onSecondaryContainer().getArgb(scheme), + android.R.attr.state_hovered to dynamicColor.onSecondaryContainer().getArgb(scheme), + android.R.attr.state_pressed to dynamicColor.onSecondaryContainer().getArgb(scheme) + ) + + val textColors = + buildColorStateList( + android.R.attr.state_checked to dynamicColor.onSecondaryContainer().getArgb(scheme), + -android.R.attr.state_checked to dynamicColor.onSurfaceVariant().getArgb(scheme), + android.R.attr.state_hovered to dynamicColor.onSecondaryContainer().getArgb(scheme), + android.R.attr.state_focused to dynamicColor.onSecondaryContainer().getArgb(scheme), + android.R.attr.state_pressed to dynamicColor.onSecondaryContainer().getArgb(scheme) + ) + + chip.chipBackgroundColor = backgroundColors + chip.chipStrokeColor = chipOutlineFilterColorList(scheme) + chip.setTextColor(textColors) + chip.checkedIconTint = iconColors + } + } + + fun themeChipAssist(chip: Chip) { + withScheme(chip.context) { scheme -> + val iconColors = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.primary().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + ON_SURFACE_OPACITY_BUTTON_DISABLED + ), + android.R.attr.state_focused to dynamicColor.primary().getArgb(scheme), + android.R.attr.state_hovered to dynamicColor.primary().getArgb(scheme), + android.R.attr.state_pressed to dynamicColor.primary().getArgb(scheme) + ) + + val textColors = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.onSurface().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + ON_SURFACE_OPACITY_BUTTON_DISABLED + ), + android.R.attr.state_hovered to dynamicColor.onSurface().getArgb(scheme), + android.R.attr.state_focused to dynamicColor.onSurface().getArgb(scheme), + android.R.attr.state_pressed to dynamicColor.onSurface().getArgb(scheme) + ) + + chip.chipStrokeColor = chipOutlineColorList(scheme) + chip.setTextColor(textColors) + chip.chipIconTint = iconColors + } + } + + fun themeChipInput(chip: Chip) { + withScheme(chip.context) { scheme -> + val iconColors = + buildColorStateList( + android.R.attr.state_enabled to dynamicColor.onSurfaceVariant().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + ON_SURFACE_OPACITY_BUTTON_DISABLED + ), + android.R.attr.state_focused to dynamicColor.onSurfaceVariant().getArgb(scheme), + android.R.attr.state_hovered to dynamicColor.onSurfaceVariant().getArgb(scheme), + android.R.attr.state_pressed to dynamicColor.onSurfaceVariant().getArgb(scheme) + ) + + chip.chipStrokeColor = chipOutlineFilterColorList(scheme) + chip.setTextColor(chipSuggestionInputTextColorList(scheme)) + chip.chipIconTint = iconColors + } + } + + private fun chipOutlineColorList(scheme: DynamicScheme) = buildColorStateList( + android.R.attr.state_enabled to dynamicColor.outlineVariant().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + ON_SURFACE_OPACITY_BUTTON_OUTLINE_DISABLED + ), + android.R.attr.state_hovered to dynamicColor.outlineVariant().getArgb(scheme), + android.R.attr.state_focused to dynamicColor.onSurfaceVariant().getArgb(scheme), + android.R.attr.state_pressed to dynamicColor.outlineVariant().getArgb(scheme) + ) + + private fun chipOutlineFilterColorList(scheme: DynamicScheme) = buildColorStateList( + android.R.attr.state_checked to dynamicColor.secondaryContainer().getArgb(scheme), + -android.R.attr.state_checked to dynamicColor.outlineVariant().getArgb(scheme) + ) + + private fun chipSuggestionInputTextColorList(scheme: DynamicScheme) = buildColorStateList( + android.R.attr.state_enabled to dynamicColor.onSurfaceVariant().getArgb(scheme), + -android.R.attr.state_enabled to + colorUtil.adjustOpacity( + dynamicColor.onSurface().getArgb(scheme), + ON_SURFACE_OPACITY_BUTTON_DISABLED + ), + android.R.attr.state_hovered to dynamicColor.onSurfaceVariant().getArgb(scheme), + android.R.attr.state_focused to dynamicColor.onSurfaceVariant().getArgb(scheme), + android.R.attr.state_pressed to dynamicColor.onSurfaceVariant().getArgb(scheme) + ) + + private fun rippleColor(scheme: DynamicScheme) = buildColorStateList( + android.R.attr.state_pressed to + colorUtil.adjustOpacity( + dynamicColor.primary().getArgb(scheme), + SURFACE_OPACITY_BUTTON_DISABLED + ) + ) + + companion object { + private const val SURFACE_OPACITY_BUTTON_DISABLED: Float = 0.12f + private const val ON_SURFACE_OPACITY_BUTTON_OUTLINE_DISABLED: Float = 0.12f + private const val ON_SURFACE_OPACITY_BUTTON_DISABLED: Float = 0.38f + } +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/SwitchColorUtils.kt b/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/SwitchColorUtils.kt index ce8ed864..e52a3ca3 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/SwitchColorUtils.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/theme/utils/SwitchColorUtils.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2022-2023 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-License-Identifier: MIT @@ -21,15 +22,9 @@ import dynamiccolor.MaterialDynamicColors internal object SwitchColorUtils { private const val SWITCH_COMPAT_TRACK_ALPHA: Int = 77 - data class SwitchColors( - val thumbColor: ColorStateList, - val trackColor: ColorStateList - ) + data class SwitchColors(val thumbColor: ColorStateList, val trackColor: ColorStateList) - fun calculateSwitchColors( - context: Context, - scheme: DynamicScheme - ): SwitchColors { + fun calculateSwitchColors(context: Context, scheme: DynamicScheme): SwitchColors { val dynamicColor = MaterialDynamicColors() val thumbUncheckedColor = @@ -55,15 +50,15 @@ internal object SwitchColorUtils { return SwitchColors( thumbColor = - ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(dynamicColor.primary().getArgb(scheme), thumbUncheckedColor) - ), + ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(dynamicColor.primary().getArgb(scheme), thumbUncheckedColor) + ), trackColor = - ColorStateList( - arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), - intArrayOf(trackColor, trackUncheckedColor) - ) + ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(trackColor, trackUncheckedColor) + ) ) } } diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/AppCompatActivityExtensions.kt b/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/AppCompatActivityExtensions.kt index 1f6f4c51..8c0aefd7 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/AppCompatActivityExtensions.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/AppCompatActivityExtensions.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2025-2026 Alper Ozturk * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: MIT */ @@ -50,9 +51,7 @@ fun AppCompatActivity.applyEdgeToEdgeWithSystemBarPadding( * @warning On Android 15+, this is a visual workaround only. The actual status bar * color cannot be modified and will remain transparent with system-managed contrast. */ -fun AppCompatActivity.initStatusBar( - @ColorInt color: Int -) { +fun AppCompatActivity.initStatusBar(@ColorInt color: Int) { window.decorView.setOnApplyWindowInsetsListener { view, insets -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { val statusBarHeight = insets.getInsets(WindowInsets.Type.statusBars()).top diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/SchemeExtensions.kt b/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/SchemeExtensions.kt index 5dd57071..aabdd239 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/SchemeExtensions.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/SchemeExtensions.kt @@ -13,54 +13,53 @@ import androidx.compose.ui.graphics.Color import dynamiccolor.DynamicScheme import dynamiccolor.MaterialDynamicColors -fun DynamicScheme.toColorScheme(): ColorScheme = - ColorScheme( - primary = Color(MaterialDynamicColors().primary().getArgb(this)), - onPrimary = Color(MaterialDynamicColors().onPrimary().getArgb(this)), - primaryContainer = Color(MaterialDynamicColors().primaryContainer().getArgb(this)), - onPrimaryContainer = Color(MaterialDynamicColors().onPrimaryContainer().getArgb(this)), - inversePrimary = Color(MaterialDynamicColors().inversePrimary().getArgb(this)), - secondary = Color(MaterialDynamicColors().secondary().getArgb(this)), - onSecondary = Color(MaterialDynamicColors().onSecondary().getArgb(this)), - secondaryContainer = Color(MaterialDynamicColors().secondaryContainer().getArgb(this)), - onSecondaryContainer = Color(MaterialDynamicColors().onSecondaryContainer().getArgb(this)), - tertiary = Color(MaterialDynamicColors().tertiary().getArgb(this)), - onTertiary = Color(MaterialDynamicColors().onTertiary().getArgb(this)), - tertiaryContainer = Color(MaterialDynamicColors().tertiaryContainer().getArgb(this)), - onTertiaryContainer = Color(MaterialDynamicColors().onTertiaryContainer().getArgb(this)), - background = Color(MaterialDynamicColors().background().getArgb(this)), - onBackground = Color(MaterialDynamicColors().onBackground().getArgb(this)), - surface = Color(MaterialDynamicColors().surface().getArgb(this)), - onSurface = Color(MaterialDynamicColors().onSurface().getArgb(this)), - surfaceVariant = Color(MaterialDynamicColors().surfaceVariant().getArgb(this)), - onSurfaceVariant = Color(MaterialDynamicColors().onSurfaceVariant().getArgb(this)), - surfaceTint = Color(MaterialDynamicColors().surfaceVariant().getArgb(this)), - inverseSurface = Color(MaterialDynamicColors().inverseSurface().getArgb(this)), - inverseOnSurface = Color(MaterialDynamicColors().inverseOnSurface().getArgb(this)), - error = Color(MaterialDynamicColors().error().getArgb(this)), - onError = Color(MaterialDynamicColors().onError().getArgb(this)), - errorContainer = Color(MaterialDynamicColors().errorContainer().getArgb(this)), - onErrorContainer = Color(MaterialDynamicColors().onErrorContainer().getArgb(this)), - outline = Color(MaterialDynamicColors().outline().getArgb(this)), - outlineVariant = Color(MaterialDynamicColors().outlineVariant().getArgb(this)), - scrim = Color(MaterialDynamicColors().scrim().getArgb(this)), - surfaceBright = Color(MaterialDynamicColors().surfaceBright().getArgb(this)), - surfaceDim = Color(MaterialDynamicColors().surfaceDim().getArgb(this)), - surfaceContainer = Color(MaterialDynamicColors().surfaceContainer().getArgb(this)), - surfaceContainerHigh = Color(MaterialDynamicColors().surfaceContainerHigh().getArgb(this)), - surfaceContainerHighest = Color(MaterialDynamicColors().surfaceContainerHighest().getArgb(this)), - surfaceContainerLow = Color(MaterialDynamicColors().surfaceContainerLow().getArgb(this)), - surfaceContainerLowest = Color(MaterialDynamicColors().surfaceContainerLowest().getArgb(this)), - primaryFixed = Color(MaterialDynamicColors().primaryFixed().getArgb(this)), - primaryFixedDim = Color(MaterialDynamicColors().primaryFixedDim().getArgb(this)), - onPrimaryFixed = Color(MaterialDynamicColors().onPrimaryFixed().getArgb(this)), - onPrimaryFixedVariant = Color(MaterialDynamicColors().onPrimaryFixedVariant().getArgb(this)), - secondaryFixed = Color(MaterialDynamicColors().secondaryFixed().getArgb(this)), - secondaryFixedDim = Color(MaterialDynamicColors().secondaryFixedDim().getArgb(this)), - onSecondaryFixed = Color(MaterialDynamicColors().onSecondaryFixed().getArgb(this)), - onSecondaryFixedVariant = Color(MaterialDynamicColors().onSecondaryFixedVariant().getArgb(this)), - tertiaryFixed = Color(MaterialDynamicColors().tertiaryFixed().getArgb(this)), - tertiaryFixedDim = Color(MaterialDynamicColors().tertiaryFixedDim().getArgb(this)), - onTertiaryFixed = Color(MaterialDynamicColors().onTertiaryFixed().getArgb(this)), - onTertiaryFixedVariant = Color(MaterialDynamicColors().onTertiaryFixedVariant().getArgb(this)) - ) +fun DynamicScheme.toColorScheme(): ColorScheme = ColorScheme( + primary = Color(MaterialDynamicColors().primary().getArgb(this)), + onPrimary = Color(MaterialDynamicColors().onPrimary().getArgb(this)), + primaryContainer = Color(MaterialDynamicColors().primaryContainer().getArgb(this)), + onPrimaryContainer = Color(MaterialDynamicColors().onPrimaryContainer().getArgb(this)), + inversePrimary = Color(MaterialDynamicColors().inversePrimary().getArgb(this)), + secondary = Color(MaterialDynamicColors().secondary().getArgb(this)), + onSecondary = Color(MaterialDynamicColors().onSecondary().getArgb(this)), + secondaryContainer = Color(MaterialDynamicColors().secondaryContainer().getArgb(this)), + onSecondaryContainer = Color(MaterialDynamicColors().onSecondaryContainer().getArgb(this)), + tertiary = Color(MaterialDynamicColors().tertiary().getArgb(this)), + onTertiary = Color(MaterialDynamicColors().onTertiary().getArgb(this)), + tertiaryContainer = Color(MaterialDynamicColors().tertiaryContainer().getArgb(this)), + onTertiaryContainer = Color(MaterialDynamicColors().onTertiaryContainer().getArgb(this)), + background = Color(MaterialDynamicColors().background().getArgb(this)), + onBackground = Color(MaterialDynamicColors().onBackground().getArgb(this)), + surface = Color(MaterialDynamicColors().surface().getArgb(this)), + onSurface = Color(MaterialDynamicColors().onSurface().getArgb(this)), + surfaceVariant = Color(MaterialDynamicColors().surfaceVariant().getArgb(this)), + onSurfaceVariant = Color(MaterialDynamicColors().onSurfaceVariant().getArgb(this)), + surfaceTint = Color(MaterialDynamicColors().surfaceVariant().getArgb(this)), + inverseSurface = Color(MaterialDynamicColors().inverseSurface().getArgb(this)), + inverseOnSurface = Color(MaterialDynamicColors().inverseOnSurface().getArgb(this)), + error = Color(MaterialDynamicColors().error().getArgb(this)), + onError = Color(MaterialDynamicColors().onError().getArgb(this)), + errorContainer = Color(MaterialDynamicColors().errorContainer().getArgb(this)), + onErrorContainer = Color(MaterialDynamicColors().onErrorContainer().getArgb(this)), + outline = Color(MaterialDynamicColors().outline().getArgb(this)), + outlineVariant = Color(MaterialDynamicColors().outlineVariant().getArgb(this)), + scrim = Color(MaterialDynamicColors().scrim().getArgb(this)), + surfaceBright = Color(MaterialDynamicColors().surfaceBright().getArgb(this)), + surfaceDim = Color(MaterialDynamicColors().surfaceDim().getArgb(this)), + surfaceContainer = Color(MaterialDynamicColors().surfaceContainer().getArgb(this)), + surfaceContainerHigh = Color(MaterialDynamicColors().surfaceContainerHigh().getArgb(this)), + surfaceContainerHighest = Color(MaterialDynamicColors().surfaceContainerHighest().getArgb(this)), + surfaceContainerLow = Color(MaterialDynamicColors().surfaceContainerLow().getArgb(this)), + surfaceContainerLowest = Color(MaterialDynamicColors().surfaceContainerLowest().getArgb(this)), + primaryFixed = Color(MaterialDynamicColors().primaryFixed().getArgb(this)), + primaryFixedDim = Color(MaterialDynamicColors().primaryFixedDim().getArgb(this)), + onPrimaryFixed = Color(MaterialDynamicColors().onPrimaryFixed().getArgb(this)), + onPrimaryFixedVariant = Color(MaterialDynamicColors().onPrimaryFixedVariant().getArgb(this)), + secondaryFixed = Color(MaterialDynamicColors().secondaryFixed().getArgb(this)), + secondaryFixedDim = Color(MaterialDynamicColors().secondaryFixedDim().getArgb(this)), + onSecondaryFixed = Color(MaterialDynamicColors().onSecondaryFixed().getArgb(this)), + onSecondaryFixedVariant = Color(MaterialDynamicColors().onSecondaryFixedVariant().getArgb(this)), + tertiaryFixed = Color(MaterialDynamicColors().tertiaryFixed().getArgb(this)), + tertiaryFixedDim = Color(MaterialDynamicColors().tertiaryFixedDim().getArgb(this)), + onTertiaryFixed = Color(MaterialDynamicColors().onTertiaryFixed().getArgb(this)), + onTertiaryFixedVariant = Color(MaterialDynamicColors().onTertiaryFixedVariant().getArgb(this)) +) diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/Strings.kt b/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/Strings.kt new file mode 100644 index 00000000..00d83581 --- /dev/null +++ b/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/Strings.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.util.extensions + +object Strings { + const val EMPTY = "" +} diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/WindowExtensions.kt b/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/WindowExtensions.kt index 6f5c9e74..6e9a699e 100644 --- a/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/WindowExtensions.kt +++ b/ui/src/main/java/com/nextcloud/android/common/ui/util/extensions/WindowExtensions.kt @@ -1,6 +1,7 @@ /* * Nextcloud Android Common Library * + * SPDX-FileCopyrightText: 2025 Alper Ozturk * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: MIT */ 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..e5d39e72 --- /dev/null +++ b/ui/src/main/res/drawable/ic_anyone.xml @@ -0,0 +1,15 @@ + + + + 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..670e8bd4 --- /dev/null +++ b/ui/src/main/res/drawable/ic_calendar.xml @@ -0,0 +1,15 @@ + + + + diff --git a/ui/src/main/res/drawable/ic_cancel.xml b/ui/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 00000000..d5489d5e --- /dev/null +++ b/ui/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/ui/src/main/res/drawable/ic_error.xml b/ui/src/main/res/drawable/ic_error.xml new file mode 100644 index 00000000..812532d0 --- /dev/null +++ b/ui/src/main/res/drawable/ic_error.xml @@ -0,0 +1,18 @@ + + + + + + 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..f3767fe0 --- /dev/null +++ b/ui/src/main/res/drawable/ic_person_add.xml @@ -0,0 +1,15 @@ + + + + diff --git a/ui/src/main/res/drawable/ic_person_add_filled.xml b/ui/src/main/res/drawable/ic_person_add_filled.xml new file mode 100644 index 00000000..97533cc8 --- /dev/null +++ b/ui/src/main/res/drawable/ic_person_add_filled.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml new file mode 100644 index 00000000..268cd2e3 --- /dev/null +++ b/ui/src/main/res/values/strings.xml @@ -0,0 +1,87 @@ + + + + OK + Cancel + + Share link + Share link (%1$d) + + Share "%s" + + Settings + + Participant + 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 yet + No result + + Copied + Copy private link + Copy public link + Send + Share public link… + + Remove share + Remove share? + This share will be permanently removed. + + Failed to fetch shares. + Pull down to try again. + Failed to remove share. + Failed to create share. + Failed to update share. + + Show recipients + Hide recipients + +%1$d + ", " + Remove recipient + Permissions for %1$s + Custom permissions + %1$s (default) + You can only grant the same or fewer permissions than the share. + + + %1$d person + %1$d people + + + %1$d group + %1$d groups + + + %1$d team + %1$d teams + + + %1$d email + %1$d emails + + + %1$d link + %1$d links + + + %1$d recipient + %1$d recipients + + + Participants + Participants + Can… + + Invited people + Anyone + \ No newline at end of file diff --git a/ui/src/test/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatterTest.kt b/ui/src/test/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatterTest.kt new file mode 100644 index 00000000..6e1098a0 --- /dev/null +++ b/ui/src/test/java/com/nextcloud/android/common/ui/share/component/property/datepicker/util/ShareDateFormatterTest.kt @@ -0,0 +1,83 @@ +/* + * Nextcloud Android Common Library + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ + +package com.nextcloud.android.common.ui.share.component.property.datepicker.util + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Before +import org.junit.Test +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +class ShareDateFormatterTest { + private lateinit var originalLocale: Locale + + @Before + fun setUp() { + originalLocale = Locale.getDefault() + } + + @After + fun tearDown() { + Locale.setDefault(originalLocale) + } + + @Test + fun `displays expiration date with Turkish format when device locale is Turkish`() { + assertLocalizedDisplay(TURKISH) + } + + @Test + fun `displays expiration date with English format when device locale is English`() { + assertLocalizedDisplay(ENGLISH) + } + + @Test + fun `displays expiration date with device locale format for other languages`() { + assertLocalizedDisplay(GERMAN) + } + + @Test + fun `expiration date display differs per device locale`() { + val turkish = displayFor(TURKISH) + val english = displayFor(ENGLISH) + val german = displayFor(GERMAN) + + assertNotEquals(turkish, english) + assertNotEquals(turkish, german) + assertNotEquals(english, german) + } + + private fun assertLocalizedDisplay(locale: Locale) { + val expected = + DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + .withLocale(locale) + .format(DATE) + + assertEquals(expected, displayFor(locale)) + } + + private fun displayFor(locale: Locale): String { + Locale.setDefault(locale) + return ShareDateFormatter().formatIsoForDisplay(ISO_DATE) + } + + private companion object { + private val TURKISH: Locale = Locale.forLanguageTag("tr") + private val ENGLISH: Locale = Locale.forLanguageTag("en") + private val GERMAN: Locale = Locale.forLanguageTag("de") + + private const val ISO_DATE = "2026-12-31" + private val DATE: LocalDate = LocalDate.of(2026, 12, 31) + } +}