diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..dfb5397a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + - package-ecosystem: "gradle" + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a3df4e25..c8de52c6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,18 +25,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: recursive - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: 17 - - uses: gradle/gradle-build-action@v2 + - uses: gradle/actions/setup-gradle@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} @@ -44,6 +44,6 @@ jobs: run: ./gradlew --no-daemon app:assembleStandardDebug - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependent-issues.yml b/.github/workflows/dependent-issues.yml index 9e2244d6..8d27c16c 100644 --- a/.github/workflows/dependent-issues.yml +++ b/.github/workflows/dependent-issues.yml @@ -51,4 +51,5 @@ jobs: # (Optional) A custom comment body. It supports `{{ dependencies }}` token. comment: > This PR/issue depends on: + {{ dependencies }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3a0db2e..a5c507e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,14 +10,14 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: true - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: 17 - - uses: gradle/gradle-build-action@v2 + - uses: gradle/actions/setup-gradle@v3 - name: Prepare keystore run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index b5c55657..2a601fc3 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -5,42 +5,42 @@ jobs: name: Tests without emulator runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: true - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: 17 - - uses: gradle/gradle-build-action@v2 + - uses: gradle/actions/setup-gradle@v3 - name: Check run: ./gradlew app:lintStandardDebug app:testStandardDebugUnitTest - name: Archive results if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: test-results + name: test-results-unit path: | app/build/outputs/lint* app/build/reports test_on_emulator: name: Tests with emulator - runs-on: ubuntu-latest-4-cores + runs-on: ubuntu-latest strategy: matrix: api-level: [31] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: true - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: 17 - - uses: gradle/gradle-build-action@v2 + - uses: gradle/gradle-build-action@v3 - name: Enable KVM group perms run: | @@ -49,7 +49,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: Cache AVD - uses: actions/cache@v3 + uses: actions/cache@v4 id: avd-cache with: path: | @@ -80,8 +80,8 @@ jobs: - name: Archive results if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: test-results + name: test-results-instrumented-${{ matrix.api-level }} path: | app/build/reports diff --git a/README.md b/README.md index ea6f69ea..d8c93106 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ News and updates: [@davx5app@fosstodon.org](https://fosstodon.org/@davx5app) Help, discussion, ideas, bug reports: [ICSx⁵ forum](https://icsx5.bitfire.at/forums/) +> [!IMPORTANT] +> +> This repository is a fork of the official [ICSx⁵](https://github.com/bitfireAT/icsx5). +> This is a major rewrite for making sure ICSx⁵ works as it should. + Contributions diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 15a559b0..00000000 --- a/app/build.gradle +++ /dev/null @@ -1,162 +0,0 @@ -import com.mikepenz.aboutlibraries.plugin.DuplicateMode - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' -apply plugin: 'com.mikepenz.aboutlibraries.plugin' -apply plugin: 'com.google.devtools.ksp' - -android { - compileSdk 34 - - namespace 'at.bitfire.icsdroid' - - defaultConfig { - applicationId "at.bitfire.icsdroid" - minSdkVersion 21 - targetSdkVersion 33 - - versionCode 73 - versionName "2.2-beta.1" - - setProperty "archivesBaseName", "icsx5-" + getVersionCode() + "-" + getVersionName() - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - - ksp { - arg("room.schemaLocation", "$projectDir/schemas".toString()) - } - } - - compileOptions { - coreLibraryDesugaringEnabled true - - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - - buildFeatures { - buildConfig = true - compose = true - dataBinding = true - viewBinding = true - } - - composeOptions { - // Keep in sync with Kotlin version: https://developer.android.com/jetpack/androidx/releases/compose-kotlin - kotlinCompilerExtensionVersion = '1.5.1' - } - - flavorDimensions = ["distribution"] - productFlavors { - standard - gplay - } - - signingConfigs { - bitfire { - storeFile file(System.getenv("ANDROID_KEYSTORE") ?: "/dev/null") - storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD") - keyAlias System.getenv("ANDROID_KEY_ALIAS") - keyPassword System.getenv("ANDROID_KEY_PASSWORD") - } - } - - buildTypes { - debug { - minifyEnabled false - } - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - signingConfig signingConfigs.bitfire - } - } - - lint { - disable 'ExtraTranslation', 'MissingTranslation', 'InvalidPackage', 'OnClick' - } - - packagingOptions { - resources { - excludes += ['META-INF/*.md'] - } - } - - androidResources { - generateLocaleConfig true - } -} - -configurations { - configureEach { - // exclude modules which are in conflict with system libraries - exclude module: "commons-logging" - exclude group: "org.json", module: "json" - - // Groovy requires SDK 26+, and it's not required, so exclude it - exclude group: 'org.codehaus.groovy' - } -} - -dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' - - implementation 'com.github.bitfireAT:cert4android:3817e62d9f173d8f8b800d24769f42cb205f560e' - implementation 'com.github.bitfireAT:ical4android:b682476' - - implementation 'androidx.activity:activity-compose:1.7.2' - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.core:core-ktx:1.10.1' - implementation 'androidx.fragment:fragment-ktx:1.6.1' - implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' - implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'androidx.work:work-runtime-ktx:2.8.1' - implementation 'com.google.android.material:material:1.9.0' - - // Jetpack Compose - def composeBom = platform("androidx.compose:compose-bom:${versions.composeBom}") - implementation composeBom - androidTestImplementation composeBom - implementation 'androidx.compose.material:material' - debugImplementation "androidx.compose.ui:ui-tooling" - implementation "androidx.compose.ui:ui-tooling-preview" - implementation 'androidx.compose.runtime:runtime-livedata:1.5.0' - implementation 'com.google.accompanist:accompanist-themeadapter-material:0.30.1' - implementation 'io.github.vanpra.compose-material-dialogs:color:0.9.0' - - implementation 'com.jaredrummler:colorpicker:1.1.0' - implementation "com.mikepenz:aboutlibraries-compose:${versions.aboutLibs}" - implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" - implementation "com.squareup.okhttp3:okhttp-brotli:${versions.okhttp}" - implementation "com.squareup.okhttp3:okhttp-coroutines:${versions.okhttp}" - implementation "joda-time:joda-time:2.12.5" - - // latest commons that don't require Java 8 - //noinspection GradleDependency - implementation 'commons-io:commons-io:2.6' - //noinspection GradleDependency - implementation 'org.apache.commons:commons-lang3:3.8.1' - - // Room Database - implementation "androidx.room:room-ktx:${versions.room}" - ksp "androidx.room:room-compiler:${versions.room}" - - // for tests - androidTestImplementation 'androidx.test:runner:1.5.2' - androidTestImplementation "androidx.test:rules:1.5.0" - androidTestImplementation "androidx.arch.core:core-testing:2.2.0" - androidTestImplementation 'junit:junit:4.13.2' - androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}" - androidTestImplementation "androidx.work:work-testing:2.8.1" - - testImplementation 'junit:junit:4.13.2' -} - -aboutLibraries { - duplicationMode = DuplicateMode.MERGE - includePlatform = false -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 00000000..3a8c9f1d --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,161 @@ +import com.mikepenz.aboutlibraries.plugin.DuplicateMode + +plugins { + alias(libs.plugins.aboutLibs) + alias(libs.plugins.android.application) + alias(libs.plugins.kapt) + alias(libs.plugins.kotlin) + alias(libs.plugins.ksp) +} + +android { + compileSdk = 34 + + namespace = "at.bitfire.icsdroid" + + defaultConfig { + applicationId = "at.bitfire.icsdroid" + minSdk = 23 + targetSdk = 34 + + versionCode = 73 + versionName = "2.2-beta.1" + + setProperty("archivesBaseName", "icsx5-$versionCode-$versionName") + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ksp { + arg("room.schemaLocation", "$projectDir/schemas") + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + buildConfig = true + compose = true + dataBinding = true + viewBinding = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + + flavorDimensions += "distribution" + productFlavors { + create("standard") {} + create("gplay") {} + } + + signingConfigs { + create("bitfire") { + storeFile = file(System.getenv("ANDROID_KEYSTORE") ?: "/dev/null") + storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD") + keyAlias = System.getenv("ANDROID_KEY_ALIAS") + keyPassword = System.getenv("ANDROID_KEY_PASSWORD") + } + } + + buildTypes { + debug { + isMinifyEnabled = false + } + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + signingConfig = signingConfigs.getByName("bitfire") + } + } + + lint { + disable.addAll( + listOf("ExtraTranslation", "MissingTranslation", "InvalidPackage", "OnClick") + ) + } + + packaging { + resources { + excludes += "META-INF/*.md" + } + } + + androidResources { + @Suppress("UnstableApiUsage") + generateLocaleConfig = true + } +} + +configurations { + configureEach { + // exclude modules which are in conflict with system libraries + exclude(module = "commons-logging") + exclude(group = "org.json", module = "json") + + // Groovy requires SDK 26+, and it"s not required, so exclude it + exclude(group = "org.codehaus.groovy") + } +} + +dependencies { + implementation(libs.kotlinx.coroutines) + coreLibraryDesugaring(libs.desugaring) + + implementation(libs.bitfire.cert4android) + implementation(libs.bitfire.ical4android) + + implementation(libs.compose.dialogs.color) + implementation(libs.compose.dialogs.core) + + implementation(libs.androidx.activityCompose) + implementation(libs.androidx.appCompat) + implementation(libs.androidx.core) + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.work.runtime) + + // Jetpack Compose + implementation(libs.compose.material3) + implementation(libs.compose.materialIconsExtended) + debugImplementation(libs.compose.ui.tooling) + implementation(libs.compose.ui.toolingPreview) + implementation(libs.compose.runtime.livedata) + + implementation(libs.aboutLibs.compose) + implementation(libs.jodaTime) + + implementation(libs.okhttp.base) + implementation(libs.okhttp.brotli) + // FIXME - Add when OkHttp 5.0.0 is stable + // implementation(libs.okhttp.coroutines) + + // latest commons that don"t require Java 8 + //noinspection GradleDependency + implementation(libs.commons.io) + //noinspection GradleDependency + implementation(libs.commons.lang3) + + // Room Database + implementation(libs.room.base) + ksp(libs.room.compiler) + + // for tests + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.arch.core.testing) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.okhttp.mockwebserver) + androidTestImplementation(libs.androidx.work.testing) + + testImplementation(libs.junit) +} + +aboutLibraries { + duplicationMode = DuplicateMode.MERGE + includePlatform = false +} diff --git a/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt b/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt index 4fba697d..3a91c5b0 100644 --- a/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt +++ b/app/src/androidTest/java/at/bitfire/icsdroid/migration/CalendarToRoomMigrationTest.kt @@ -22,7 +22,7 @@ import androidx.work.testing.SynchronousExecutor import androidx.work.testing.TestListenableWorkerBuilder import androidx.work.testing.WorkManagerTestInitHelper import at.bitfire.ical4android.AndroidCalendar -import at.bitfire.ical4android.util.MiscUtils.ContentProviderClientHelper.closeCompat +import at.bitfire.ical4android.util.MiscUtils.closeCompat import at.bitfire.icsdroid.AppAccount import at.bitfire.icsdroid.Constants.TAG import at.bitfire.icsdroid.SyncWorker diff --git a/app/src/androidTest/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragmentTest.kt b/app/src/androidTest/java/at/bitfire/icsdroid/model/ValidationModelTest.kt similarity index 88% rename from app/src/androidTest/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragmentTest.kt rename to app/src/androidTest/java/at/bitfire/icsdroid/model/ValidationModelTest.kt index fd265f5e..ee376e66 100644 --- a/app/src/androidTest/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragmentTest.kt +++ b/app/src/androidTest/java/at/bitfire/icsdroid/model/ValidationModelTest.kt @@ -2,13 +2,15 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.icsdroid.ui +package at.bitfire.icsdroid.model import android.app.Application import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.platform.app.InstrumentationRegistry import at.bitfire.ical4android.Css3Color import at.bitfire.icsdroid.HttpUtils.toAndroidUri +import at.bitfire.icsdroid.ui.ResourceInfo +import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.AfterClass @@ -18,7 +20,7 @@ import org.junit.BeforeClass import org.junit.Rule import org.junit.Test -class AddCalendarValidationFragmentTest { +class ValidationModelTest { companion object { @@ -96,15 +98,13 @@ class AddCalendarValidationFragmentTest { private fun validate(iCal: String): ResourceInfo { server.enqueue(MockResponse().setBody(iCal)) - val model = AddCalendarValidationFragment.ValidationModel(app, server.url("/").toAndroidUri(), null, null) - // wait for result - var result: ResourceInfo? = null - while (result == null) { - result = model.result.value - Thread.sleep(50) + val model = ValidationModel(app) + runBlocking { + // Wait until the validation completed + model.validate(server.url("/").toAndroidUri(), null, null).join() } - return result + return model.result.value!! } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d5699e0c..8ceae7dd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ @@ -39,10 +38,9 @@ android:dataExtractionRules="@xml/data_extraction_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:name=".MyApp" android:networkSecurityConfig="@xml/network_security" android:requestLegacyExternalStorage="true" - android:theme="@style/AppTheme" + android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" android:enableOnBackInvokedCallback="true" tools:ignore="UnusedAttribute"> @@ -69,8 +67,7 @@ @@ -78,9 +75,8 @@ @@ -117,15 +113,14 @@ - + android:parentActivityName=".ui.views.CalendarListActivity" /> diff --git a/app/src/main/java/at/bitfire/icsdroid/AccountAuthenticatorService.kt b/app/src/main/java/at/bitfire/icsdroid/AccountAuthenticatorService.kt index c338671a..884db605 100644 --- a/app/src/main/java/at/bitfire/icsdroid/AccountAuthenticatorService.kt +++ b/app/src/main/java/at/bitfire/icsdroid/AccountAuthenticatorService.kt @@ -13,7 +13,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.IBinder -import at.bitfire.icsdroid.ui.AddCalendarActivity +import at.bitfire.icsdroid.ui.views.AddCalendarActivity class AccountAuthenticatorService: Service() { diff --git a/app/src/main/java/at/bitfire/icsdroid/AsyncUtils.kt b/app/src/main/java/at/bitfire/icsdroid/AsyncUtils.kt new file mode 100644 index 00000000..c571086f --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/AsyncUtils.kt @@ -0,0 +1,32 @@ +package at.bitfire.icsdroid + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import okio.IOException +import kotlin.coroutines.resumeWithException + +/** + * Executes the call suspendfully. + * + * FIXME - Should be removed and replaced with the official function when 5.0.0 stable is released. + * + * @see Source + */ +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun Call.executeAsync(): Response = suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { + this.cancel() + } + this.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + continuation.resume(value = response, onCancellation = { call.cancel() }) + } + }) +} diff --git a/app/src/main/java/at/bitfire/icsdroid/CalendarFetcher.kt b/app/src/main/java/at/bitfire/icsdroid/CalendarFetcher.kt index 635fd4cf..df008e5d 100644 --- a/app/src/main/java/at/bitfire/icsdroid/CalendarFetcher.kt +++ b/app/src/main/java/at/bitfire/icsdroid/CalendarFetcher.kt @@ -13,7 +13,6 @@ import at.bitfire.icsdroid.HttpUtils.toUri import okhttp3.Credentials import okhttp3.MediaType import okhttp3.Request -import okhttp3.executeAsync import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream @@ -155,8 +154,8 @@ open class CalendarFetcher( // 20x response.isSuccessful -> onSuccess( - response.body.byteStream(), - response.body.contentType(), + response.body!!.byteStream(), + response.body!!.contentType(), response.header("ETag"), response.header("Last-Modified")?.let { HttpUtils.parseDate(it)?.time diff --git a/app/src/main/java/at/bitfire/icsdroid/HttpClient.kt b/app/src/main/java/at/bitfire/icsdroid/HttpClient.kt index d3e4e3fb..ce874033 100644 --- a/app/src/main/java/at/bitfire/icsdroid/HttpClient.kt +++ b/app/src/main/java/at/bitfire/icsdroid/HttpClient.kt @@ -6,6 +6,7 @@ package at.bitfire.icsdroid import android.content.Context import at.bitfire.cert4android.CustomCertManager +import kotlinx.coroutines.flow.MutableStateFlow import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Response @@ -15,29 +16,29 @@ import java.util.concurrent.TimeUnit import javax.net.ssl.SSLContext class HttpClient private constructor( - context: Context + context: Context ) { companion object { private var INSTANCE: HttpClient? = null + private val appInForeground = MutableStateFlow(false) + @Synchronized fun get(context: Context): HttpClient { INSTANCE?.let { return it } - HttpClient(context.applicationContext).let { - INSTANCE = it - return it - } + return HttpClient(context.applicationContext) + .also { INSTANCE = it } } fun setForeground(foreground: Boolean) { - INSTANCE?.certManager?.appInForeground = foreground + appInForeground.tryEmit(foreground) } } // CustomCertManager is Closeable, but HttpClient will live as long as the application is in memory, // so we don't need to close it - private val certManager = CustomCertManager(context) + private val certManager = CustomCertManager(context, appInForeground = appInForeground) private val sslContext = SSLContext.getInstance("TLS") init { @@ -45,21 +46,22 @@ class HttpClient private constructor( } val okHttpClient: OkHttpClient = OkHttpClient.Builder() - .addNetworkInterceptor(BrotliInterceptor) - .addNetworkInterceptor(UserAgentInterceptor) - .followRedirects(false) - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .sslSocketFactory(sslContext.socketFactory, certManager) - .hostnameVerifier(certManager.hostnameVerifier(OkHostnameVerifier)) - .build() + .addNetworkInterceptor(BrotliInterceptor) + .addNetworkInterceptor(UserAgentInterceptor) + .followRedirects(false) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .sslSocketFactory(sslContext.socketFactory, certManager) + .hostnameVerifier(certManager.HostnameVerifier(OkHostnameVerifier)) + .build() object UserAgentInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request().newBuilder() - .header("User-Agent", Constants.USER_AGENT) + val request = chain.request() + .newBuilder() + .header("User-Agent", Constants.USER_AGENT) return chain.proceed(request.build()) } diff --git a/app/src/main/java/at/bitfire/icsdroid/MyApp.kt b/app/src/main/java/at/bitfire/icsdroid/MyApp.kt deleted file mode 100644 index ed6c0b16..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/MyApp.kt +++ /dev/null @@ -1,33 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid - -import android.app.Application -import androidx.appcompat.app.AppCompatDelegate - -class MyApp: Application() { - - companion object { - - fun setNightMode(forceDarkMode: Boolean) { - AppCompatDelegate.setDefaultNightMode( - if (forceDarkMode) - AppCompatDelegate.MODE_NIGHT_YES - else - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - ) - } - - } - - - override fun onCreate() { - super.onCreate() - - // dark mode is not persisted over app restarts - setNightMode(Settings(this).forceDarkMode()) - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt b/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt index bd35caf4..fad5017c 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt @@ -16,7 +16,7 @@ import at.bitfire.icsdroid.calendar.LocalCalendar import at.bitfire.icsdroid.calendar.LocalEvent import at.bitfire.icsdroid.db.AppDatabase import at.bitfire.icsdroid.db.entity.Subscription -import at.bitfire.icsdroid.ui.EditCalendarActivity +import at.bitfire.icsdroid.ui.views.EditCalendarActivity import at.bitfire.icsdroid.ui.NotificationUtils import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.PropertyList @@ -188,7 +188,7 @@ class ProcessEventsTask( context, 0, errorIntent, - PendingIntent.FLAG_UPDATE_CURRENT + NotificationUtils.flagImmutableCompat + PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE ) ) .setAutoCancel(true) diff --git a/app/src/main/java/at/bitfire/icsdroid/Settings.kt b/app/src/main/java/at/bitfire/icsdroid/Settings.kt index 30ffc1b1..e03fd895 100644 --- a/app/src/main/java/at/bitfire/icsdroid/Settings.kt +++ b/app/src/main/java/at/bitfire/icsdroid/Settings.kt @@ -42,9 +42,6 @@ class Settings(context: Context) { prefs.edit() .putBoolean(FORCE_DARK_MODE, force) .apply() - - // actually set dark mode - MyApp.setNightMode(force) } } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt index 85e850d8..4f3e28be 100644 --- a/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt +++ b/app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt @@ -53,6 +53,7 @@ data class Subscription( * @param calendar The legacy calendar to create the subscription from. * @return A new [Subscription] that has the contents of [calendar]. */ + @Suppress("DEPRECATION") fun fromLegacyCalendar(calendar: LocalCalendar) = Subscription( calendarId = calendar.id, diff --git a/app/src/main/java/at/bitfire/icsdroid/model/CreateSubscriptionModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/CreateSubscriptionModel.kt new file mode 100644 index 00000000..af1da87e --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/model/CreateSubscriptionModel.kt @@ -0,0 +1,76 @@ +package at.bitfire.icsdroid.model + +import android.app.Application +import android.net.Uri +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import at.bitfire.icsdroid.Constants +import at.bitfire.icsdroid.SyncWorker +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.entity.Credential +import at.bitfire.icsdroid.db.entity.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class CreateSubscriptionModel(application: Application) : AndroidViewModel(application) { + + private val database = AppDatabase.getInstance(getApplication()) + private val subscriptionsDao = database.subscriptionsDao() + private val credentialsDao = database.credentialsDao() + + val success = MutableLiveData(false) + val errorMessage = MutableLiveData(null) + val isCreating = MutableLiveData(false) + + /** + * Creates a new subscription taking the data from the given models. + */ + fun create( + subscriptionSettingsModel: SubscriptionSettingsModel, + credentialsModel: CredentialsModel, + ) { + viewModelScope.launch(Dispatchers.IO) { + isCreating.postValue(true) + try { + val subscription = Subscription( + displayName = subscriptionSettingsModel.title.value!!, + url = Uri.parse(subscriptionSettingsModel.url.value), + color = subscriptionSettingsModel.color.value, + ignoreEmbeddedAlerts = subscriptionSettingsModel.ignoreAlerts.value ?: false, + defaultAlarmMinutes = subscriptionSettingsModel.defaultAlarmMinutes.value, + defaultAllDayAlarmMinutes = subscriptionSettingsModel.defaultAllDayAlarmMinutes.value, + ) + + /** A list of all the ids of the inserted rows */ + val id = subscriptionsDao.add(subscription) + + // Create the credential in the IO thread + if (credentialsModel.requiresAuth.value == true) { + // If the subscription requires credentials, create them + val username = credentialsModel.username.value + val password = credentialsModel.password.value + if (username != null && password != null) { + val credential = Credential( + subscriptionId = id, + username = username, + password = password + ) + credentialsDao.create(credential) + } + } + + // sync the subscription to reflect the changes in the calendar provider + SyncWorker.run(getApplication()) + + success.postValue(true) + } catch (e: Exception) { + Log.e(Constants.TAG, "Couldn't create calendar", e) + errorMessage.postValue(e.localizedMessage ?: e.message) + } finally { + isCreating.postValue(false) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/model/CredentialsModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/CredentialsModel.kt new file mode 100644 index 00000000..18b5341f --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/model/CredentialsModel.kt @@ -0,0 +1,17 @@ +package at.bitfire.icsdroid.model + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import at.bitfire.icsdroid.db.entity.Credential + +class CredentialsModel : ViewModel() { + val requiresAuth = MutableLiveData(false) + val username = MutableLiveData(null) + val password = MutableLiveData(null) + + val isInsecure = MutableLiveData(false) + + fun equalsCredential(credential: Credential) = + username.value == credential.username + && password.value == credential.password +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/model/EditSubscriptionModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/EditSubscriptionModel.kt new file mode 100644 index 00000000..2dce32d8 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/model/EditSubscriptionModel.kt @@ -0,0 +1,81 @@ +package at.bitfire.icsdroid.model + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.SyncWorker +import at.bitfire.icsdroid.db.AppDatabase +import at.bitfire.icsdroid.db.entity.Credential +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class EditSubscriptionModel( + application: Application, + private val subscriptionId: Long +): AndroidViewModel(application) { + + private val db = AppDatabase.getInstance(application) + private val credentialsDao = db.credentialsDao() + private val subscriptionsDao = db.subscriptionsDao() + + val successMessage = MutableLiveData() + + val subscriptionWithCredential = db.subscriptionsDao().getWithCredentialsByIdLive(subscriptionId) + + /** + * Updates the loaded subscription from the data provided by the view models. + */ + fun updateSubscription( + subscriptionSettingsModel: SubscriptionSettingsModel, + credentialsModel: CredentialsModel + ) { + viewModelScope.launch(Dispatchers.IO) { + subscriptionWithCredential.value?.let { subscriptionWithCredentials -> + val subscription = subscriptionWithCredentials.subscription + + val newSubscription = subscription.copy( + displayName = subscriptionSettingsModel.title.value ?: subscription.displayName, + color = subscriptionSettingsModel.color.value, + defaultAlarmMinutes = subscriptionSettingsModel.defaultAlarmMinutes.value, + defaultAllDayAlarmMinutes = subscriptionSettingsModel.defaultAllDayAlarmMinutes.value, + ignoreEmbeddedAlerts = subscriptionSettingsModel.ignoreAlerts.value ?: false + ) + subscriptionsDao.update(newSubscription) + + if (credentialsModel.requiresAuth.value == true) { + val username = credentialsModel.username.value + val password = credentialsModel.password.value + if (username != null && password != null) + credentialsDao.upsert(Credential(subscriptionId, username, password)) + } else + credentialsDao.removeBySubscriptionId(subscriptionId) + + // notify UI about success + successMessage.postValue(getApplication().getString(R.string.edit_calendar_saved)) + + // sync the subscription to reflect the changes in the calendar provider + SyncWorker.run(getApplication(), forceResync = true) + } + } + } + + /** + * Removes the loaded subscription. + */ + fun removeSubscription() { + viewModelScope.launch(Dispatchers.IO) { + subscriptionWithCredential.value?.let { subscriptionWithCredentials -> + subscriptionsDao.delete(subscriptionWithCredentials.subscription) + + // sync the subscription to reflect the changes in the calendar provider + SyncWorker.run(getApplication()) + + // notify UI about success + successMessage.postValue(getApplication().getString(R.string.edit_calendar_deleted)) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionSettingsModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionSettingsModel.kt new file mode 100644 index 00000000..44da91ef --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/model/SubscriptionSettingsModel.kt @@ -0,0 +1,40 @@ +package at.bitfire.icsdroid.model + +import android.net.Uri +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import at.bitfire.icsdroid.HttpUtils +import at.bitfire.icsdroid.db.entity.Subscription +import java.net.URISyntaxException + +class SubscriptionSettingsModel : ViewModel() { + val url = MutableLiveData(null) + val urlError = MutableLiveData(null) + val title = MutableLiveData(null) + val color = MutableLiveData(null) + val ignoreAlerts = MutableLiveData(false) + val defaultAlarmMinutes = MutableLiveData(null) + val defaultAllDayAlarmMinutes = MutableLiveData(null) + + val supportsAuthentication = MediatorLiveData(false).apply { + addSource(url) { + val uri = try { + Uri.parse(it) + } catch (e: URISyntaxException) { + return@addSource + } catch (_: NullPointerException) { + return@addSource + } + value = HttpUtils.supportsAuthentication(uri) + } + } + + fun equalsSubscription(subscription: Subscription) = + url.value == subscription.url.toString() + && title.value == subscription.displayName + && color.value == subscription.color + && ignoreAlerts.value == subscription.ignoreEmbeddedAlerts + && defaultAlarmMinutes.value == subscription.defaultAlarmMinutes + && defaultAllDayAlarmMinutes.value == subscription.defaultAllDayAlarmMinutes +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/model/ValidationModel.kt b/app/src/main/java/at/bitfire/icsdroid/model/ValidationModel.kt new file mode 100644 index 00000000..29402e9e --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/model/ValidationModel.kt @@ -0,0 +1,103 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.model + +import android.app.Application +import android.net.Uri +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import at.bitfire.ical4android.Css3Color +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.ICalendar +import at.bitfire.icsdroid.CalendarFetcher +import at.bitfire.icsdroid.Constants +import at.bitfire.icsdroid.HttpUtils.toURI +import at.bitfire.icsdroid.HttpUtils.toUri +import at.bitfire.icsdroid.ui.ResourceInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.fortuna.ical4j.model.property.Color +import okhttp3.MediaType +import java.io.InputStream +import java.io.InputStreamReader + +class ValidationModel(application: Application): AndroidViewModel(application) { + + val isVerifyingUrl = MutableLiveData(false) + + val result = MutableLiveData(null) + + fun validate( + originalUri: Uri, + username: String?, + password: String? + ) = viewModelScope.launch(Dispatchers.IO) { + try { + Log.i(Constants.TAG, "Validating Webcal feed $originalUri (authentication: $username)") + + isVerifyingUrl.postValue(true) + + val info = ResourceInfo(originalUri) + val downloader = object: CalendarFetcher(getApplication(), originalUri) { + override fun onSuccess( + data: InputStream, + contentType: MediaType?, + eTag: String?, + lastModified: Long?, + displayName: String? + ) { + InputStreamReader(data, contentType?.charset() ?: Charsets.UTF_8).use { reader -> + val properties = mutableMapOf() + val events = Event.eventsFromReader(reader, properties) + + info.calendarName = properties[ICalendar.CALENDAR_NAME] ?: displayName + info.calendarColor = + // try COLOR first + properties[Color.PROPERTY_NAME]?.let { colorValue -> + Css3Color.colorFromString(colorValue) + } ?: + // try X-APPLE-CALENDAR-COLOR second + try { + properties[ICalendar.CALENDAR_COLOR]?.let { colorValue -> + Css3Color.colorFromString(colorValue) + } + } catch (e: IllegalArgumentException) { + Log.w(Constants.TAG, "Couldn't parse calendar COLOR", e) + null + } + info.eventsFound = events.size + } + + result.postValue(info) + } + + override fun onNewPermanentUrl(target: Uri) { + Log.i(Constants.TAG, "Got permanent redirect when validating, saving new URL: $target") + val location = uri.toURI().resolve(target.toURI()) + info.uri = location.toUri() + } + + override fun onError(error: Exception) { + Log.e(Constants.TAG, "Couldn't validate calendar", error) + info.exception = error + result.postValue(info) + } + } + + downloader.username = username + downloader.password = password + + // directly ask for confirmation of custom certificates + downloader.inForeground = true + + downloader.fetch() + } finally { + isVerifyingUrl.postValue(false) + } + } + +} diff --git a/app/src/main/java/at/bitfire/icsdroid/service/ComposableStartupService.kt b/app/src/main/java/at/bitfire/icsdroid/service/ComposableStartupService.kt new file mode 100644 index 00000000..341ecadc --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/service/ComposableStartupService.kt @@ -0,0 +1,37 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.service + +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.lifecycle.LiveData + +/** + * Used for interactions between flavors. + * + * Provides the possibility to display some composable (intended for dialogs) if a given condition + * is met. + */ +interface ComposableStartupService { + /** + * Will be called every time the main activity is created. + * @param activity The calling activity + */ + fun initialize(activity: AppCompatActivity) + + /** + * Provides a stateful response to whether this composable should be shown or not. + * @return A [LiveData] that can be observed, and will make [Content] visible when `true`. + */ + @Composable + fun shouldShow(): LiveData + + /** + * The content to display. It's not constrained, will be rendered together with the main UI. + * Usually an `AlertDialog`. + */ + @Composable + fun Content() +} diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt deleted file mode 100644 index beb9c661..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarActivity.kt +++ /dev/null @@ -1,46 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.ui - -import android.os.Bundle -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import at.bitfire.icsdroid.calendar.LocalCalendar - -class AddCalendarActivity: AppCompatActivity() { - - companion object { - const val EXTRA_TITLE = "title" - const val EXTRA_COLOR = "color" - } - - private val subscriptionSettingsModel by viewModels() - - - override fun onCreate(inState: Bundle?) { - super.onCreate(inState) - - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - if (inState == null) { - supportFragmentManager - .beginTransaction() - .add(android.R.id.content, AddCalendarEnterUrlFragment()) - .commit() - - intent?.apply { - data?.let { uri -> - subscriptionSettingsModel.url.value = uri.toString() - } - getStringExtra(EXTRA_TITLE)?.let { - subscriptionSettingsModel.title.value = it - } - if (hasExtra(EXTRA_COLOR)) - subscriptionSettingsModel.color.value = getIntExtra(EXTRA_COLOR, LocalCalendar.DEFAULT_COLOR) - } - } - } - -} diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt deleted file mode 100644 index caac2416..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarDetailsFragment.kt +++ /dev/null @@ -1,145 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.ui - -import android.app.Application -import android.net.Uri -import android.os.Bundle -import android.util.Log -import android.view.* -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.viewModelScope -import at.bitfire.icsdroid.Constants -import at.bitfire.icsdroid.R -import at.bitfire.icsdroid.SyncWorker -import at.bitfire.icsdroid.db.AppDatabase -import at.bitfire.icsdroid.db.entity.Credential -import at.bitfire.icsdroid.db.entity.Subscription -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -class AddCalendarDetailsFragment: Fragment() { - - private val subscriptionSettingsModel by activityViewModels() - private val credentialsModel by activityViewModels() - private val model by activityViewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val invalidateOptionsMenu = Observer { - requireActivity().invalidateOptionsMenu() - } - subscriptionSettingsModel.title.observe(this, invalidateOptionsMenu) - subscriptionSettingsModel.color.observe(this, invalidateOptionsMenu) - subscriptionSettingsModel.ignoreAlerts.observe(this, invalidateOptionsMenu) - subscriptionSettingsModel.defaultAlarmMinutes.observe(this, invalidateOptionsMenu) - subscriptionSettingsModel.defaultAllDayAlarmMinutes.observe(this, invalidateOptionsMenu) - - // Set the default value to null so that the visibility of the summary is updated - subscriptionSettingsModel.defaultAlarmMinutes.value = null - subscriptionSettingsModel.defaultAllDayAlarmMinutes.value = null - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { - val v = inflater.inflate(R.layout.add_calendar_details, container, false) - setHasOptionsMenu(true) - - // Handle status changes - model.success.observe(viewLifecycleOwner) { success -> - if (success) { - // success, show notification and close activity - Toast.makeText(requireActivity(), requireActivity().getString(R.string.add_calendar_created),Toast.LENGTH_LONG).show() - - requireActivity().finish() - } - } - model.errorMessage.observe(viewLifecycleOwner) { message -> - Toast.makeText(requireActivity(), message, Toast.LENGTH_LONG).show() - } - - return v - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.fragment_create_calendar, menu) - } - - override fun onPrepareOptionsMenu(menu: Menu) { - val itemGo = menu.findItem(R.id.create_calendar) - itemGo.isEnabled = !subscriptionSettingsModel.title.value.isNullOrBlank() - } - - override fun onOptionsItemSelected(item: MenuItem) = - if (item.itemId == R.id.create_calendar) { - model.create(subscriptionSettingsModel, credentialsModel) - true - } else - false - - - class SubscriptionModel(application: Application) : AndroidViewModel(application) { - - private val database = AppDatabase.getInstance(getApplication()) - private val subscriptionsDao = database.subscriptionsDao() - private val credentialsDao = database.credentialsDao() - - val success = MutableLiveData(false) - val errorMessage = MutableLiveData() - - /** - * Creates a new subscription taking the data from the given models. - */ - fun create( - subscriptionSettingsModel: SubscriptionSettingsFragment.SubscriptionSettingsModel, - credentialsModel: CredentialsFragment.CredentialsModel, - ) { - viewModelScope.launch(Dispatchers.IO) { - try { - val subscription = Subscription( - displayName = subscriptionSettingsModel.title.value!!, - url = Uri.parse(subscriptionSettingsModel.url.value), - color = subscriptionSettingsModel.color.value, - ignoreEmbeddedAlerts = subscriptionSettingsModel.ignoreAlerts.value ?: false, - defaultAlarmMinutes = subscriptionSettingsModel.defaultAlarmMinutes.value, - defaultAllDayAlarmMinutes = subscriptionSettingsModel.defaultAllDayAlarmMinutes.value, - ) - - /** A list of all the ids of the inserted rows */ - val id = subscriptionsDao.add(subscription) - - // Create the credential in the IO thread - if (credentialsModel.requiresAuth.value == true) { - // If the subscription requires credentials, create them - val username = credentialsModel.username.value - val password = credentialsModel.password.value - if (username != null && password != null) { - val credential = Credential( - subscriptionId = id, - username = username, - password = password - ) - credentialsDao.create(credential) - } - } - - // sync the subscription to reflect the changes in the calendar provider - SyncWorker.run(getApplication()) - - success.postValue(true) - } catch (e: Exception) { - Log.e(Constants.TAG, "Couldn't create calendar", e) - errorMessage.postValue(e.localizedMessage) - } - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt deleted file mode 100644 index 034731d0..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarEnterUrlFragment.kt +++ /dev/null @@ -1,182 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.ui - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Observer -import at.bitfire.icsdroid.Constants -import at.bitfire.icsdroid.HttpUtils -import at.bitfire.icsdroid.R -import at.bitfire.icsdroid.databinding.AddCalendarEnterUrlBinding -import java.net.URI -import java.net.URISyntaxException -import okhttp3.HttpUrl.Companion.toHttpUrl - -class AddCalendarEnterUrlFragment: Fragment() { - - private val subscriptionSettingsModel by activityViewModels() - private val credentialsModel by activityViewModels() - private lateinit var binding: AddCalendarEnterUrlBinding - - private val pickFile = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> - if (uri != null) { - // keep the picked file accessible after the first sync and reboots - requireActivity().contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - - binding.url.editText?.setText(uri.toString()) - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { - val invalidate = Observer { - requireActivity().invalidateOptionsMenu() - } - arrayOf( - subscriptionSettingsModel.url, - credentialsModel.requiresAuth, - credentialsModel.username, - credentialsModel.password - ).forEach { - it.observe(viewLifecycleOwner, invalidate) - } - - binding = AddCalendarEnterUrlBinding.inflate(inflater, container, false) - binding.lifecycleOwner = this - binding.model = subscriptionSettingsModel - - setHasOptionsMenu(true) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - binding.pickStorageFile.setOnClickListener { - pickFile.launch(arrayOf("text/calendar")) - } - - validateUri() - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.enter_url_fragment, menu) - } - - override fun onPrepareOptionsMenu(menu: Menu) { - val itemNext = menu.findItem(R.id.next) - - val uri = validateUri() - - val authOK = - if (credentialsModel.requiresAuth.value == true) - !credentialsModel.username.value.isNullOrEmpty() && !credentialsModel.password.value.isNullOrEmpty() - else - true - itemNext.isEnabled = uri != null && authOK - } - - - /* dynamic changes */ - - private fun validateUri(): Uri? { - var errorMsg: String? = null - - var uri: Uri - try { - try { - uri = Uri.parse(subscriptionSettingsModel.url.value ?: return null) - } catch (e: URISyntaxException) { - Log.d(Constants.TAG, "Invalid URL", e) - errorMsg = e.localizedMessage - return null - } - - Log.i(Constants.TAG, uri.toString()) - - if (uri.scheme.equals("webcal", true)) { - uri = uri.buildUpon().scheme("http").build() - subscriptionSettingsModel.url.value = uri.toString() - return null - } else if (uri.scheme.equals("webcals", true)) { - uri = uri.buildUpon().scheme("https").build() - subscriptionSettingsModel.url.value = uri.toString() - return null - } - - val supportsAuthenticate = HttpUtils.supportsAuthentication(uri) - binding.credentials.visibility = if (supportsAuthenticate) View.VISIBLE else View.GONE - when (uri.scheme?.lowercase()) { - "content" -> { - // SAF file, no need for auth - } - "http", "https" -> { - // check whether the URL is valid - try { - uri.toString().toHttpUrl() - } catch (e: IllegalArgumentException) { - Log.w(Constants.TAG, "Invalid URI", e) - errorMsg = e.localizedMessage - return null - } - - // extract user name and password from URL - uri.userInfo?.let { userInfo -> - val credentials = userInfo.split(':') - credentialsModel.requiresAuth.value = true - credentialsModel.username.value = credentials.elementAtOrNull(0) - credentialsModel.password.value = credentials.elementAtOrNull(1) - - val urlWithoutPassword = URI(uri.scheme, null, uri.host, uri.port, uri.path, uri.query, null) - subscriptionSettingsModel.url.value = urlWithoutPassword.toString() - return null - } - } - else -> { - errorMsg = getString(R.string.add_calendar_need_valid_uri) - return null - } - } - - // warn if auth. required and not using HTTPS - binding.insecureAuthenticationWarning.visibility = - if (credentialsModel.requiresAuth.value == true && !uri.scheme.equals("https", true)) - View.VISIBLE - else - View.GONE - } finally { - binding.url.error = errorMsg - } - return uri - } - - - /* actions */ - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.next) { - - // flush the credentials if auth toggle is disabled - if (credentialsModel.requiresAuth.value != true) { - credentialsModel.username.value = null - credentialsModel.password.value = null - } - - AddCalendarValidationFragment().show(parentFragmentManager, "validation") - return true - } - return false - } - -} diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragment.kt deleted file mode 100644 index e2a6b1ad..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AddCalendarValidationFragment.kt +++ /dev/null @@ -1,173 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.ui - -import android.app.Application -import android.app.Dialog -import android.app.ProgressDialog -import android.net.Uri -import android.os.Bundle -import android.util.Log -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.lifecycle.* -import at.bitfire.ical4android.Css3Color -import at.bitfire.ical4android.Event -import at.bitfire.ical4android.ICalendar -import at.bitfire.icsdroid.CalendarFetcher -import at.bitfire.icsdroid.Constants -import at.bitfire.icsdroid.HttpClient -import at.bitfire.icsdroid.HttpUtils.toURI -import at.bitfire.icsdroid.HttpUtils.toUri -import at.bitfire.icsdroid.R -import java.io.InputStream -import java.io.InputStreamReader -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import net.fortuna.ical4j.model.property.Color -import okhttp3.MediaType - -class AddCalendarValidationFragment: DialogFragment() { - - private val subscriptionSettingsModel by activityViewModels() - private val credentialsModel by activityViewModels() - - private val validationModel by viewModels { - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - val uri = Uri.parse(subscriptionSettingsModel.url.value ?: throw IllegalArgumentException("No URL given"))!! - val authenticate = credentialsModel.requiresAuth.value ?: false - return ValidationModel( - requireActivity().application, - uri, - if (authenticate) credentialsModel.username.value else null, - if (authenticate) credentialsModel.password.value else null - ) as T - } - } - } - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - validationModel.result.observe(this, Observer { info -> - requireDialog().dismiss() - - val exception = info.exception - if (exception == null) { - subscriptionSettingsModel.url.value = info.uri.toString() - - if (subscriptionSettingsModel.color.value == null) - subscriptionSettingsModel.color.value = info.calendarColor ?: resources.getColor(R.color.lightblue) - - if (subscriptionSettingsModel.title.value.isNullOrBlank()) - subscriptionSettingsModel.title.value = info.calendarName ?: info.uri.toString() - - parentFragmentManager - .beginTransaction() - .replace(android.R.id.content, AddCalendarDetailsFragment()) - .addToBackStack(null) - .commitAllowingStateLoss() - } else { - val errorMessage = - exception.localizedMessage ?: exception.message ?: exception.toString() - AlertFragment.create(errorMessage, exception).show(parentFragmentManager, null) - } - }) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val progress = ProgressDialog(activity) - progress.setMessage(getString(R.string.add_calendar_validating)) - return progress - } - - override fun onPause() { - super.onPause() - HttpClient.setForeground(false) - } - - override fun onResume() { - super.onResume() - HttpClient.setForeground(true) - } - - - /* activityModel and data source */ - - class ValidationModel( - val context: Application, - val originalUri: Uri, - val username: String?, - val password: String? - ): ViewModel() { - - val result = MutableLiveData() - - init { - viewModelScope.launch(Dispatchers.Default) { - validate() - } - } - - private suspend fun validate() { - Log.i(Constants.TAG, "Validating Webcal feed $originalUri (authentication: $username)") - - val info = ResourceInfo(originalUri) - val downloader = object: CalendarFetcher(context, originalUri) { - override fun onSuccess(data: InputStream, contentType: MediaType?, eTag: String?, lastModified: Long?, displayName: String?) { - InputStreamReader(data, contentType?.charset() ?: Charsets.UTF_8).use { reader -> - val properties = mutableMapOf() - val events = Event.eventsFromReader(reader, properties) - - info.calendarName = properties[ICalendar.CALENDAR_NAME] ?: displayName - info.calendarColor = - // try COLOR first - properties[Color.PROPERTY_NAME]?.let { colorValue -> - Css3Color.colorFromString(colorValue) - } ?: - // try X-APPLE-CALENDAR-COLOR second - try { - properties[ICalendar.CALENDAR_COLOR]?.let { colorValue -> - Css3Color.colorFromString(colorValue) - } - } catch (e: IllegalArgumentException) { - Log.w(Constants.TAG, "Couldn't parse calendar COLOR", e) - null - } - info.eventsFound = events.size - } - - result.postValue(info) - } - - override fun onNewPermanentUrl(target: Uri) { - Log.i(Constants.TAG, "Got permanent redirect when validating, saving new URL: $target") - val location = uri.toURI().resolve(target.toURI()) - info.uri = location.toUri() - } - - override fun onError(error: Exception) { - Log.e(Constants.TAG, "Couldn't validate calendar", error) - info.exception = error - result.postValue(info) - } - } - - downloader.username = username - downloader.password = password - - // directly ask for confirmation of custom certificates - downloader.inForeground = true - - downloader.fetch() - } - - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/AlertFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/AlertFragment.kt deleted file mode 100644 index eda02f29..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/AlertFragment.kt +++ /dev/null @@ -1,58 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.ui - -import android.app.Dialog -import android.os.Bundle -import androidx.core.app.ShareCompat -import androidx.fragment.app.DialogFragment -import at.bitfire.icsdroid.R -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import java.io.PrintWriter -import java.io.StringWriter - -class AlertFragment: DialogFragment() { - - companion object { - - const val ARG_MESSAGE = "message" - const val ARG_THROWABLE = "throwable" - - fun create(message: String, throwable: Throwable? = null): AlertFragment { - val frag = AlertFragment() - val args = Bundle(2) - args.putString(ARG_MESSAGE, message) - args.putSerializable(ARG_THROWABLE, throwable) - frag.arguments = args - return frag - } - - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val args = requireArguments() - val message = args.getString(ARG_MESSAGE).orEmpty() - val dialog = MaterialAlertDialogBuilder(requireActivity()) - .setMessage(message) - .setPositiveButton(android.R.string.ok) { _, _ -> } - .setNeutralButton(R.string.alert_share_details) { _, _ -> - val details = StringWriter() - details.append(message) - - (args.getSerializable(ARG_THROWABLE) as? Throwable)?.let { ex -> - details.append("\n\n") - ex.printStackTrace(PrintWriter(details)) - } - - val share = ShareCompat.IntentBuilder(requireActivity()) - .setType("text/plain") - .setText(details.toString()) - .createChooserIntent() - startActivity(share) - } - return dialog.create() - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/ColorButton.kt b/app/src/main/java/at/bitfire/icsdroid/ui/ColorButton.kt deleted file mode 100644 index 7bf90db3..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/ColorButton.kt +++ /dev/null @@ -1,40 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.ui - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.drawable.shapes.OvalShape -import android.util.AttributeSet -import android.view.View - -class ColorButton( - context: Context, - attributeSet: AttributeSet? -): View(context, attributeSet) { - - private var shape = OvalShape() - private var paint = Paint() - - init { - paint.isAntiAlias = true - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - shape.resize(w.toFloat(), h.toFloat()) - } - - override fun onDraw(canvas: Canvas) { - shape.draw(canvas, paint) - } - - - fun setColor(color: Int) { - paint.color = 0xFF000000.toInt() or color - invalidate() - } - -} diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt deleted file mode 100644 index ccbef15a..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/ColorPickerActivity.kt +++ /dev/null @@ -1,59 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.ui - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.result.contract.ActivityResultContract -import androidx.appcompat.app.AppCompatActivity -import at.bitfire.icsdroid.calendar.LocalCalendar -import com.jaredrummler.android.colorpicker.ColorPickerDialog -import com.jaredrummler.android.colorpicker.ColorPickerDialogListener - -class ColorPickerActivity: AppCompatActivity(), ColorPickerDialogListener { - - companion object { - const val EXTRA_COLOR = "color" - } - - class Contract: ActivityResultContract() { - override fun createIntent(context: Context, input: Int?): Intent = Intent(context, ColorPickerActivity::class.java).apply { - putExtra(EXTRA_COLOR, input) - } - - override fun parseResult(resultCode: Int, intent: Intent?): Int = intent?.getIntExtra(EXTRA_COLOR, LocalCalendar.DEFAULT_COLOR) ?: LocalCalendar.DEFAULT_COLOR - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - if (savedInstanceState == null) { - val builder = ColorPickerDialog.newBuilder() - .setShowAlphaSlider(false) - .setAllowCustom(true) - - intent?.apply { - if (hasExtra(EXTRA_COLOR)) - builder.setColor(getIntExtra(EXTRA_COLOR, LocalCalendar.DEFAULT_COLOR)) - } - - val dialog = builder.create() - dialog.show(supportFragmentManager, "color") - } - } - - override fun onColorSelected(dialogId: Int, color: Int) { - val result = Intent() - result.putExtra(EXTRA_COLOR, color) - setResult(0, result) - finish() - } - - override fun onDialogDismissed(dialogId: Int) { - finish() - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/CredentialsFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/CredentialsFragment.kt deleted file mode 100644 index 4a20dbc4..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/CredentialsFragment.kt +++ /dev/null @@ -1,51 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.ui - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import at.bitfire.icsdroid.databinding.CredentialsBinding - -class CredentialsFragment: Fragment() { - - val model by activityViewModels() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { - val binding = CredentialsBinding.inflate(inflater, container, false) - binding.lifecycleOwner = this - binding.model = model - - model.requiresAuth.observe(viewLifecycleOwner) { requiresAuth -> - binding.inputs.visibility = if (requiresAuth) View.VISIBLE else View.GONE - } - - return binding.root - } - - class CredentialsModel : ViewModel() { - var originalRequiresAuth: Boolean? = null - var originalUsername: String? = null - var originalPassword: String? = null - - val requiresAuth = MutableLiveData() - val username = MutableLiveData() - val password = MutableLiveData() - - init { - requiresAuth.value = false - } - - fun dirty() = requiresAuth.value != originalRequiresAuth || - username.value != originalUsername || - password.value != originalPassword - } - -} diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt deleted file mode 100644 index ad8dce5b..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/EditCalendarActivity.kt +++ /dev/null @@ -1,338 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.ui - -import android.app.Application -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.Toast -import androidx.activity.addCallback -import androidx.activity.viewModels -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.ShareCompat -import androidx.databinding.DataBindingUtil -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.FragmentTransaction -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import at.bitfire.icsdroid.HttpUtils -import at.bitfire.icsdroid.R -import at.bitfire.icsdroid.SyncWorker -import at.bitfire.icsdroid.databinding.EditCalendarBinding -import at.bitfire.icsdroid.db.AppDatabase -import at.bitfire.icsdroid.db.dao.SubscriptionsDao -import at.bitfire.icsdroid.db.entity.Credential -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -class EditCalendarActivity: AppCompatActivity() { - - companion object { - const val EXTRA_SUBSCRIPTION_ID = "subscriptionId" - const val EXTRA_ERROR_MESSAGE = "errorMessage" - const val EXTRA_THROWABLE = "errorThrowable" - } - - private val subscriptionSettingsModel by viewModels() - private val credentialsModel by viewModels() - - private val model by viewModels { - object: ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - val subscriptionId = intent.getLongExtra(EXTRA_SUBSCRIPTION_ID, -1) - return SubscriptionModel(application, subscriptionId) as T - } - } - } - - lateinit var binding: EditCalendarBinding - - - override fun onCreate(inState: Bundle?) { - super.onCreate(inState) - - model.subscriptionWithCredential.observe(this) { data -> - if (data != null) - onSubscriptionLoaded(data) - } - - val invalidate = Observer { - invalidateOptionsMenu() - } - arrayOf( - subscriptionSettingsModel.title, - subscriptionSettingsModel.color, - subscriptionSettingsModel.ignoreAlerts, - subscriptionSettingsModel.defaultAlarmMinutes, - subscriptionSettingsModel.defaultAllDayAlarmMinutes, - credentialsModel.requiresAuth, - credentialsModel.username, - credentialsModel.password - ).forEach { element -> - element.observe(this, invalidate) - } - - binding = DataBindingUtil.setContentView(this, R.layout.edit_calendar) - binding.lifecycleOwner = this - binding.model = model - - // handle status changes - model.successMessage.observe(this) { message -> - if (message != null) { - Toast.makeText(this, message, Toast.LENGTH_LONG).show() - finish() - } - } - - // show error message from calling intent, if available - if (inState == null) - intent.getStringExtra(EXTRA_ERROR_MESSAGE)?.let { error -> - AlertFragment.create(error, intent.getSerializableExtra(EXTRA_THROWABLE) as? Throwable) - .show(supportFragmentManager, null) - } - - onBackPressedDispatcher.addCallback { - if (dirty()) { - // If the form is dirty, warn the user about losing changes - supportFragmentManager.beginTransaction() - .add(SaveDismissDialogFragment(), null) - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - .commit() - } else - // Otherwise, simply finish the activity - finish() - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.edit_calendar_activity, menu) - return true - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - val dirty = dirty() - menu.findItem(R.id.delete) - .setEnabled(!dirty) - .setVisible(!dirty) - - menu.findItem(R.id.cancel) - .setEnabled(dirty) - .setVisible(dirty) - - // if local file, hide authentication fragment - val uri = model.subscriptionWithCredential.value?.subscription?.url - binding.credentials.visibility = - if (uri != null && HttpUtils.supportsAuthentication(uri)) - View.VISIBLE - else - View.GONE - - val titleOK = !subscriptionSettingsModel.title.value.isNullOrBlank() - val authOK = credentialsModel.run { - if (requiresAuth.value == true) - username.value != null && password.value != null - else - true - } - menu.findItem(R.id.save) - .setEnabled(dirty && titleOK && authOK) - .setVisible(dirty && titleOK && authOK) - return true - } - - private fun onSubscriptionLoaded(subscriptionWithCredential: SubscriptionsDao.SubscriptionWithCredential) { - val subscription = subscriptionWithCredential.subscription - - subscriptionSettingsModel.url.value = subscription.url.toString() - subscription.displayName.let { - subscriptionSettingsModel.originalTitle = it - subscriptionSettingsModel.title.value = it - } - subscription.color.let { - subscriptionSettingsModel.originalColor = it - subscriptionSettingsModel.color.value = it - } - subscription.ignoreEmbeddedAlerts.let { - subscriptionSettingsModel.originalIgnoreAlerts = it - subscriptionSettingsModel.ignoreAlerts.postValue(it) - } - subscription.defaultAlarmMinutes.let { - subscriptionSettingsModel.originalDefaultAlarmMinutes = it - subscriptionSettingsModel.defaultAlarmMinutes.postValue(it) - } - subscription.defaultAllDayAlarmMinutes.let { - subscriptionSettingsModel.originalDefaultAllDayAlarmMinutes = it - subscriptionSettingsModel.defaultAllDayAlarmMinutes.postValue(it) - } - - val credential = subscriptionWithCredential.credential - val requiresAuth = credential != null - credentialsModel.originalRequiresAuth = requiresAuth - credentialsModel.requiresAuth.value = requiresAuth - - if (credential != null) { - credential.username.let { username -> - credentialsModel.originalUsername = username - credentialsModel.username.value = username - } - credential.password.let { password -> - credentialsModel.originalPassword = password - credentialsModel.password.value = password - } - } - } - - - /* user actions */ - - fun onSave(item: MenuItem?) { - model.updateSubscription(subscriptionSettingsModel, credentialsModel) - } - - fun onAskDelete(item: MenuItem) { - supportFragmentManager.beginTransaction() - .add(DeleteDialogFragment(), null) - .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - .commit() - } - - private fun onDelete() { - model.removeSubscription() - } - - fun onCancel(item: MenuItem?) { - finish() - } - - fun onShare(item: MenuItem) { - model.subscriptionWithCredential.value?.let { (subscription, _) -> - ShareCompat.IntentBuilder(this) - .setSubject(subscription.displayName) - .setText(subscription.url.toString()) - .setType("text/plain") - .setChooserTitle(R.string.edit_calendar_send_url) - .startChooser() - } - } - - private fun dirty(): Boolean = subscriptionSettingsModel.dirty() || credentialsModel.dirty() - - - /* view model and data source */ - - class SubscriptionModel( - application: Application, - private val subscriptionId: Long - ): AndroidViewModel(application) { - - private val db = AppDatabase.getInstance(application) - private val credentialsDao = db.credentialsDao() - private val subscriptionsDao = db.subscriptionsDao() - - val successMessage = MutableLiveData() - - val subscriptionWithCredential = db.subscriptionsDao().getWithCredentialsByIdLive(subscriptionId) - - /** - * Updates the loaded subscription from the data provided by the view models. - */ - fun updateSubscription( - subscriptionSettingsModel: SubscriptionSettingsFragment.SubscriptionSettingsModel, - credentialsModel: CredentialsFragment.CredentialsModel - ) { - viewModelScope.launch(Dispatchers.IO) { - subscriptionWithCredential.value?.let { subscriptionWithCredentials -> - val subscription = subscriptionWithCredentials.subscription - - val newSubscription = subscription.copy( - displayName = subscriptionSettingsModel.title.value ?: subscription.displayName, - color = subscriptionSettingsModel.color.value, - defaultAlarmMinutes = subscriptionSettingsModel.defaultAlarmMinutes.value, - defaultAllDayAlarmMinutes = subscriptionSettingsModel.defaultAllDayAlarmMinutes.value, - ignoreEmbeddedAlerts = subscriptionSettingsModel.ignoreAlerts.value ?: false - ) - subscriptionsDao.update(newSubscription) - - if (credentialsModel.requiresAuth.value == true) { - val username = credentialsModel.username.value - val password = credentialsModel.password.value - if (username != null && password != null) - credentialsDao.upsert(Credential(subscriptionId, username, password)) - } else - credentialsDao.removeBySubscriptionId(subscriptionId) - - // notify UI about success - successMessage.postValue(getApplication().getString(R.string.edit_calendar_saved)) - - // sync the subscription to reflect the changes in the calendar provider - SyncWorker.run(getApplication(), forceResync = true) - } - } - } - - /** - * Removes the loaded subscription. - */ - fun removeSubscription() { - viewModelScope.launch(Dispatchers.IO) { - subscriptionWithCredential.value?.let { subscriptionWithCredentials -> - subscriptionsDao.delete(subscriptionWithCredentials.subscription) - - // sync the subscription to reflect the changes in the calendar provider - SyncWorker.run(getApplication()) - - // notify UI about success - successMessage.postValue(getApplication().getString(R.string.edit_calendar_deleted)) - } - } - } - - } - - - /** "Really delete?" dialog */ - class DeleteDialogFragment: DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?) = - AlertDialog.Builder(requireActivity()) - .setMessage(R.string.edit_calendar_really_delete) - .setPositiveButton(R.string.edit_calendar_delete) { dialog, _ -> - dialog.dismiss() - (activity as EditCalendarActivity?)?.onDelete() - } - .setNegativeButton(R.string.edit_calendar_cancel) { dialog, _ -> - dialog.dismiss() - } - .create() - - } - - /** "Save or dismiss" dialog */ - class SaveDismissDialogFragment: DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?) = - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.edit_calendar_unsaved_changes) - .setPositiveButton(R.string.edit_calendar_save) { dialog, _ -> - dialog.dismiss() - (activity as? EditCalendarActivity)?.onSave(null) - } - .setNegativeButton(R.string.edit_calendar_dismiss) { dialog, _ -> - dialog.dismiss() - (activity as? EditCalendarActivity)?.onCancel(null) - } - .create() - - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt index d5c607f7..2c05efd2 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/InfoActivity.kt @@ -9,10 +9,8 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.util.Log -import android.widget.TextView import android.widget.Toast import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column @@ -20,47 +18,45 @@ 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.verticalScroll -import androidx.compose.material.AlertDialog -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import androidx.core.graphics.drawable.toBitmap import androidx.core.text.HtmlCompat import at.bitfire.icsdroid.BuildConfig import at.bitfire.icsdroid.Constants import at.bitfire.icsdroid.R -import com.google.accompanist.themeadapter.material.MdcTheme +import at.bitfire.icsdroid.ui.partials.ExtendedTopAppBar +import at.bitfire.icsdroid.ui.partials.GenericAlertDialog +import at.bitfire.icsdroid.ui.theme.setContentThemed import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer +import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults class InfoActivity: ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { + setContentThemed { MainLayout() } } @@ -84,48 +80,54 @@ class InfoActivity: ComponentActivity() { } + @OptIn(ExperimentalMaterial3Api::class) @Composable @Preview fun MainLayout() { - MdcTheme { - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - IconButton({ onNavigateUp() }) { - Icon( - imageVector = Icons.Filled.ArrowBack, - contentDescription = null - ) - } - }, - title = { - Text( - stringResource(R.string.app_name) + Scaffold( + topBar = { + ExtendedTopAppBar( + navigationIcon = { + IconButton({ onNavigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null ) - }, - actions = { - IconButton({ showWebSite() }) { - Icon( - painter = painterResource(R.drawable.ic_public), - contentDescription = stringResource(R.string.app_info_web_site) - ) - } - IconButton({ showMastodon() }) { - Icon( - painter = painterResource(R.drawable.mastodon_white), - contentDescription = stringResource(R.string.app_info_mastodon) - ) - } } + }, + title = { + Text( + stringResource(R.string.app_name) + ) + }, + actions = { + IconButton({ showWebSite() }) { + Icon( + painter = painterResource(R.drawable.ic_public), + contentDescription = stringResource(R.string.app_info_web_site) + ) + } + IconButton({ showMastodon() }) { + Icon( + painter = painterResource(R.drawable.mastodon_white), + contentDescription = stringResource(R.string.app_info_mastodon) + ) + } + } + ) + } + ) { contentPadding -> + Column(Modifier.padding(contentPadding)) { + Header() + License() + LibrariesContainer( + colors = LibraryDefaults.libraryColors( + backgroundColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + badgeBackgroundColor = MaterialTheme.colorScheme.primary, + badgeContentColor = MaterialTheme.colorScheme.onPrimary, ) - } - ) { contentPadding -> - Column(Modifier.padding(contentPadding)) { - Header() - License() - LibrariesContainer() - } + ) } } } @@ -150,8 +152,8 @@ class InfoActivity: ComponentActivity() { ) Text( text = stringResource(R.string.app_name), - style = MaterialTheme.typography.h5, - color = MaterialTheme.colors.onBackground + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground ) Text( text = stringResource( @@ -159,9 +161,9 @@ class InfoActivity: ComponentActivity() { BuildConfig.VERSION_NAME, BuildConfig.FLAVOR ), - style = MaterialTheme.typography.subtitle1, - color = MaterialTheme.colors.onBackground, - modifier = Modifier.alpha(ContentAlpha.medium) + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Normal ) } } @@ -197,20 +199,15 @@ class InfoActivity: ComponentActivity() { } @Composable - fun TextDialog(@StringRes text: Int, state: MutableState, buttons: @Composable () -> Unit = {}) { - AlertDialog( - text = { - AndroidView({ context -> - TextView(context).also { - it.text = HtmlCompat.fromHtml( - getString(text).replace("\n", "
"), - HtmlCompat.FROM_HTML_MODE_COMPACT) - } - }, modifier = Modifier.verticalScroll(rememberScrollState())) + fun TextDialog(@StringRes text: Int, state: MutableState) { + GenericAlertDialog( + content = { Text(HtmlCompat.fromHtml( + getString(text).replace("\n", "
"), + HtmlCompat.FROM_HTML_MODE_COMPACT).toString()) }, + confirmButton = stringResource(R.string.edit_calendar_dismiss) to { + state.value = false }, - buttons = buttons, - onDismissRequest = { state.value = false } - ) + ) { state.value = false } } } \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt b/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt index 510f7261..26a379b2 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/NotificationUtils.kt @@ -12,6 +12,7 @@ import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.ui.views.CalendarListActivity object NotificationUtils { @@ -19,14 +20,6 @@ object NotificationUtils { const val NOTIFY_PERMISSION = 0 - - val flagImmutableCompat: Int = - if (Build.VERSION.SDK_INT >= 23) - PendingIntent.FLAG_IMMUTABLE - else - 0 - - fun createChannels(context: Context): NotificationManager { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -52,7 +45,7 @@ object NotificationUtils { .setContentTitle(context.getString(R.string.sync_permission_required)) .setContentText(context.getString(R.string.sync_permission_required_sync_calendar)) .setCategory(NotificationCompat.CATEGORY_ERROR) - .setContentIntent(PendingIntent.getActivity(context, 0, askPermissionsIntent, PendingIntent.FLAG_UPDATE_CURRENT + flagImmutableCompat)) + .setContentIntent(PendingIntent.getActivity(context, 0, askPermissionsIntent, PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_IMMUTABLE)) .setAutoCancel(true) .setLocalOnly(true) .build() diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/StartupFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/StartupFragment.kt deleted file mode 100644 index f7822cdf..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/StartupFragment.kt +++ /dev/null @@ -1,13 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.ui - -import androidx.appcompat.app.AppCompatActivity - -interface StartupFragment { - - fun initialize(activity: AppCompatActivity) - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/SubscriptionSettingsFragment.kt b/app/src/main/java/at/bitfire/icsdroid/ui/SubscriptionSettingsFragment.kt deleted file mode 100644 index ee365aa1..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/SubscriptionSettingsFragment.kt +++ /dev/null @@ -1,161 +0,0 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.icsdroid.ui - -import android.os.Bundle -import android.text.InputType -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.widget.CompoundButton.OnCheckedChangeListener -import android.widget.EditText -import android.widget.TextView -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModel -import at.bitfire.icsdroid.R -import at.bitfire.icsdroid.databinding.SubscriptionSettingsBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.switchmaterial.SwitchMaterial -import org.joda.time.Minutes -import org.joda.time.format.PeriodFormat - -class SubscriptionSettingsFragment : Fragment() { - - private val model by activityViewModels() - - private lateinit var binding: SubscriptionSettingsBinding - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View { - binding = SubscriptionSettingsBinding.inflate(inflater, container, false) - binding.lifecycleOwner = this - binding.model = model - - model.defaultAlarmMinutes.observe( - viewLifecycleOwner, - defaultAlarmObserver( - binding.defaultAlarmSwitch, - binding.defaultAlarmText, - model.defaultAlarmMinutes - ) - ) - model.defaultAllDayAlarmMinutes.observe( - viewLifecycleOwner, - defaultAlarmObserver( - binding.defaultAlarmAllDaySwitch, - binding.defaultAlarmAllDayText, - model.defaultAllDayAlarmMinutes - ) - ) - - val colorPickerContract = registerForActivityResult(ColorPickerActivity.Contract()) { color -> - model.color.value = color - } - binding.color.setOnClickListener { - colorPickerContract.launch(model.color.value) - } - - return binding.root - } - - /** - * Provides an observer for the default alarm fields. - * @param switch The switch view that updates the currently stored minutes. - * @param textView The viewer for the current value of the stored minutes. - * @param selectedMinutes The LiveData instance that holds the currently selected amount of minutes. - */ - private fun defaultAlarmObserver( - switch: SwitchMaterial, - textView: TextView, - selectedMinutes: MutableLiveData - ) = Observer { min: Long? -> - switch.isChecked = min != null - // We add the listener once the switch has an initial value - switch.setOnCheckedChangeListener(getOnCheckedChangeListener(switch, selectedMinutes)) - - if (min == null) - textView.text = getString(R.string.add_calendar_alarms_default_none) - else { - val alarmPeriodText = PeriodFormat.wordBased().print(Minutes.minutes(min.toInt())) - textView.text = getString(R.string.add_calendar_alarms_default_description, alarmPeriodText) - } - } - - /** - * Provides an [OnCheckedChangeListener] for watching the checked changes of a switch that - * provides the alarm time in minutes for a given parameter. Also holds the alert dialog that - * asks the user the amount of time to set. - * @param switch The switch that is going to update the selection of minutes. - * @param observable The state holder of the amount of minutes selected. - */ - private fun getOnCheckedChangeListener( - switch: SwitchMaterial, - observable: MutableLiveData - ) = OnCheckedChangeListener { _, checked -> - if (!checked) { - observable.value = null - return@OnCheckedChangeListener - } - - val editText = EditText(requireContext()).apply { - setHint(R.string.default_alarm_dialog_hint) - isSingleLine = true - maxLines = 1 - imeOptions = EditorInfo.IME_ACTION_DONE - inputType = InputType.TYPE_CLASS_NUMBER - - addTextChangedListener { txt -> - val text = txt?.toString() - val num = text?.toLongOrNull() - error = if (text == null || text.isBlank() || num == null) - getString(R.string.default_alarm_dialog_error) - else - null - } - } - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.default_alarm_dialog_title) - .setMessage(R.string.default_alarm_dialog_message) - .setView(editText) - .setPositiveButton(R.string.default_alarm_dialog_set) { dialog, _ -> - if (editText.error == null) { - observable.value = editText.text?.toString()?.toLongOrNull() - dialog.dismiss() - } - } - .setOnCancelListener { - switch.isChecked = false - } - .create() - .show() - } - - class SubscriptionSettingsModel : ViewModel() { - var url = MutableLiveData() - - var originalTitle: String? = null - val title = MutableLiveData() - - var originalColor: Int? = null - val color = MutableLiveData() - - var originalIgnoreAlerts: Boolean? = null - val ignoreAlerts = MutableLiveData() - - var originalDefaultAlarmMinutes: Long? = null - val defaultAlarmMinutes = MutableLiveData() - - var originalDefaultAllDayAlarmMinutes: Long? = null - val defaultAllDayAlarmMinutes = MutableLiveData() - - fun dirty(): Boolean = originalTitle != title.value || originalColor != color.value || originalIgnoreAlerts != ignoreAlerts.value || - originalDefaultAlarmMinutes != defaultAlarmMinutes.value || originalDefaultAllDayAlarmMinutes != defaultAllDayAlarmMinutes.value - } - -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/dialog/SyncIntervalDialog.kt b/app/src/main/java/at/bitfire/icsdroid/ui/dialog/SyncIntervalDialog.kt deleted file mode 100644 index f9933208..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/dialog/SyncIntervalDialog.kt +++ /dev/null @@ -1,39 +0,0 @@ -package at.bitfire.icsdroid.ui.dialog - -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringArrayResource -import androidx.compose.ui.tooling.preview.Preview -import at.bitfire.icsdroid.R -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -@Composable -fun SyncIntervalDialog( - currentInterval: Long, - onSetSyncInterval: (Long) -> Unit, - onDismiss: () -> Unit -) { - val syncIntervalValues = stringArrayResource(R.array.set_sync_interval_seconds).map { it.toLong() } - val currentIntervalIdx = syncIntervalValues.indexOf(currentInterval) - - MaterialAlertDialogBuilder(LocalContext.current) - .setTitle(R.string.set_sync_interval_title) - .setSingleChoiceItems(R.array.set_sync_interval_names, currentIntervalIdx) { dialog, newIdx -> - onSetSyncInterval(syncIntervalValues[newIdx]) - dialog.dismiss() - } - .setOnDismissListener { - onDismiss() - } - .show() -} - -@Preview -@Composable -fun SyncIntervalDialog_Preview() { - SyncIntervalDialog( - -1, // only manually - onSetSyncInterval = {}, - onDismiss = {} - ) -} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/reusable/ActionCard.kt b/app/src/main/java/at/bitfire/icsdroid/ui/partials/ActionCard.kt similarity index 79% rename from app/src/main/java/at/bitfire/icsdroid/ui/reusable/ActionCard.kt rename to app/src/main/java/at/bitfire/icsdroid/ui/partials/ActionCard.kt index 43309dc2..5586f957 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/reusable/ActionCard.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/partials/ActionCard.kt @@ -1,12 +1,12 @@ -package at.bitfire.icsdroid.ui.reusable +package at.bitfire.icsdroid.ui.partials import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.Card -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TextButton +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -24,9 +24,8 @@ fun ActionCard( modifier: Modifier = Modifier, onAction: () -> Unit ) { - Card( - modifier = modifier, - elevation = 3.dp + ElevatedCard( + modifier = modifier ) { Column( modifier = Modifier @@ -36,14 +35,14 @@ fun ActionCard( Text( text = title, modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.h6 + style = MaterialTheme.typography.titleLarge ) Text( text = message, modifier = Modifier .fillMaxWidth() .padding(top = 8.dp), - style = MaterialTheme.typography.body1 + style = MaterialTheme.typography.bodyLarge ) TextButton( onClick = onAction, diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/partials/AlertDialog.kt b/app/src/main/java/at/bitfire/icsdroid/ui/partials/AlertDialog.kt new file mode 100644 index 00000000..92fb5aa2 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/partials/AlertDialog.kt @@ -0,0 +1,56 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.ui.partials + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.app.ShareCompat +import at.bitfire.icsdroid.R +import java.io.PrintWriter +import java.io.StringWriter + +@Composable +fun AlertDialog( + message: String, + throwable: Throwable? = null, + onDismissRequest: () -> Unit +) { + val context = LocalContext.current + + AlertDialog( + onDismissRequest = onDismissRequest, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton( + onClick = { + val details = StringWriter() + details.append(message) + + if (throwable != null) { + details.append("\n\n") + throwable.printStackTrace(PrintWriter(details)) + } + + val share = ShareCompat.IntentBuilder(context) + .setType("text/plain") + .setText(details.toString()) + .createChooserIntent() + context.startActivity(share) + } + ) { + Text(stringResource(R.string.alert_share_details)) + } + } + ) +} diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/list/CalendarListItem.kt b/app/src/main/java/at/bitfire/icsdroid/ui/partials/CalendarListItem.kt similarity index 85% rename from app/src/main/java/at/bitfire/icsdroid/ui/list/CalendarListItem.kt rename to app/src/main/java/at/bitfire/icsdroid/ui/partials/CalendarListItem.kt index 067e9d27..0737da51 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/list/CalendarListItem.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/partials/CalendarListItem.kt @@ -1,4 +1,4 @@ -package at.bitfire.icsdroid.ui.list +package at.bitfire.icsdroid.ui.partials import android.net.Uri import androidx.compose.foundation.clickable @@ -6,14 +6,12 @@ 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.ContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +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.graphics.toArgb -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -21,7 +19,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import at.bitfire.icsdroid.R import at.bitfire.icsdroid.db.entity.Subscription -import at.bitfire.icsdroid.ui.reusable.ColorCircle import java.text.DateFormat import java.util.Date @@ -48,13 +45,13 @@ fun CalendarListItem( ) { Text( text = subscription.url.toString(), - style = MaterialTheme.typography.caption, + style = MaterialTheme.typography.bodySmall, modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colors.onBackground.copy(ContentAlpha.medium) + color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = subscription.displayName, - style = MaterialTheme.typography.body1, + style = MaterialTheme.typography.bodyLarge, modifier = Modifier.fillMaxWidth() ) Text( @@ -62,15 +59,15 @@ fun CalendarListItem( DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT) .format(Date(lastSync)) } ?: stringResource(R.string.calendar_list_not_synced_yet), - style = MaterialTheme.typography.body2, + style = MaterialTheme.typography.bodyMedium, modifier = Modifier.fillMaxWidth() ) subscription.errorMessage?.let { errorMessage -> Text( text = errorMessage, - style = MaterialTheme.typography.body2, + style = MaterialTheme.typography.bodyMedium, modifier = Modifier.fillMaxWidth(), - color = colorResource(R.color.redorange) + color = MaterialTheme.colorScheme.error ) } } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/reusable/ColorCircle.kt b/app/src/main/java/at/bitfire/icsdroid/ui/partials/ColorCircle.kt similarity index 92% rename from app/src/main/java/at/bitfire/icsdroid/ui/reusable/ColorCircle.kt rename to app/src/main/java/at/bitfire/icsdroid/ui/partials/ColorCircle.kt index 4c37da63..7edbfee0 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/reusable/ColorCircle.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/partials/ColorCircle.kt @@ -1,9 +1,9 @@ -package at.bitfire.icsdroid.ui.reusable +package at.bitfire.icsdroid.ui.partials import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Surface +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/partials/ColorPickerDialog.kt b/app/src/main/java/at/bitfire/icsdroid/ui/partials/ColorPickerDialog.kt new file mode 100644 index 00000000..94c890ca --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/partials/ColorPickerDialog.kt @@ -0,0 +1,54 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.ui.partials + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import at.bitfire.icsdroid.calendar.LocalCalendar +import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState +import com.maxkeppeler.sheets.color.ColorDialog +import com.maxkeppeler.sheets.color.models.ColorConfig +import com.maxkeppeler.sheets.color.models.ColorSelection +import com.maxkeppeler.sheets.color.models.MultipleColors +import com.maxkeppeler.sheets.color.models.SingleColor + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ColorPickerDialog( + initialColor: Int, + onSelectColor: (color: Int) -> Unit, + onDialogDismissed: () -> Unit, +) { + val templateColors = MultipleColors.ColorsInt( + LocalCalendar.DEFAULT_COLOR, + // 2014 Material Design colors (shade 700) + 0xFFD32F2F.toInt(), + 0xFFC2185B.toInt(), + 0xFF7B1FA2.toInt(), + 0xFF512DA8.toInt(), + 0xFF303F9F.toInt(), + 0xFF1976D2.toInt(), + 0xFF0288D1.toInt(), + 0xFF0097A7.toInt(), + 0xFF00796B.toInt(), + 0xFF388E3C.toInt(), + 0xFF689F38.toInt(), + 0xFFAFB42B.toInt(), + 0xFFFBC02D.toInt(), + 0xFFFFA000.toInt(), + ) + + ColorDialog( + state = rememberUseCaseState(visible = true, onCloseRequest = { onDialogDismissed() }), + selection = ColorSelection( + selectedColor = SingleColor(initialColor), + onSelectColor = onSelectColor, + ), + config = ColorConfig( + templateColors = templateColors, + allowCustomColorAlphaValues = false + ), + ) +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/partials/ExtendedTopAppBar.kt b/app/src/main/java/at/bitfire/icsdroid/ui/partials/ExtendedTopAppBar.kt new file mode 100644 index 00000000..f0dcc4f5 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/partials/ExtendedTopAppBar.kt @@ -0,0 +1,41 @@ +package at.bitfire.icsdroid.ui.partials + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import at.bitfire.icsdroid.ui.theme.offwhite + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExtendedTopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + scrolledContainerColor = MaterialTheme.colorScheme.primary, + navigationIconContentColor = offwhite, + titleContentColor = offwhite, + actionIconContentColor = offwhite, + ), + scrollBehavior: TopAppBarScrollBehavior? = null +) { + TopAppBar( + title, + modifier, + navigationIcon, + actions, + windowInsets, + colors, + scrollBehavior + ) +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/partials/GenericAlertDialog.kt b/app/src/main/java/at/bitfire/icsdroid/ui/partials/GenericAlertDialog.kt new file mode 100644 index 00000000..24503a95 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/partials/GenericAlertDialog.kt @@ -0,0 +1,56 @@ +package at.bitfire.icsdroid.ui.partials + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview + +/** + * Provides a generic [AlertDialog] with some utilities. + * @param confirmButton The first argument is the text of the button, the second one the callback. + * @param dismissButton The first argument is the text of the button, the second one the callback. + * @param title If any, the title to show in the dialog. + * @param content Usually a [Text] element, though it can be whatever composable. + * @param onDismissRequest Requested by the dialog when it should be closed. + */ +@Composable +fun GenericAlertDialog( + confirmButton: Pair Unit>, + dismissButton: Pair Unit>? = null, + title: String? = null, + content: (@Composable () -> Unit)? = null, + onDismissRequest: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = title?.let { + { Text(it) } + }, + text = content, + dismissButton = dismissButton?.let { (text, onClick) -> + { + TextButton(onClick = { onClick() }) { Text(text) } + } + }, + confirmButton = { + val (text, onClick) = confirmButton + TextButton(onClick = { onClick() }) { Text(text) } + } + ) +} + +@Preview +@Composable +fun GenericAlertDialog_Preview() { + GenericAlertDialog( + confirmButton = "OK" to {}, + dismissButton = "Cancel" to {}, + title = "Hello!", + content = { + Text("Hello again!") + } + ) { + + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/partials/SwitchSetting.kt b/app/src/main/java/at/bitfire/icsdroid/ui/partials/SwitchSetting.kt new file mode 100644 index 00000000..0db995ca --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/partials/SwitchSetting.kt @@ -0,0 +1,45 @@ +package at.bitfire.icsdroid.ui.partials + +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.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.graphics.Color + +@Composable +fun SwitchSetting( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit = {} +) { + Column( + Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + } + Text( + text = description, + color = Color.Gray, + style = MaterialTheme.typography.bodyMedium, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/partials/SyncIntervalDialog.kt b/app/src/main/java/at/bitfire/icsdroid/ui/partials/SyncIntervalDialog.kt new file mode 100644 index 00000000..de507509 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/partials/SyncIntervalDialog.kt @@ -0,0 +1,66 @@ +package at.bitfire.icsdroid.ui.partials + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ListItem +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import at.bitfire.icsdroid.R + +@Composable +fun SyncIntervalDialog( + currentInterval: Long, + onSetSyncInterval: (Long) -> Unit, + onDismiss: () -> Unit +) { + val syncIntervalNames = stringArrayResource(R.array.set_sync_interval_names) + val syncIntervalValues = stringArrayResource(R.array.set_sync_interval_seconds).map { it.toLong() } + val currentIntervalIdx = syncIntervalValues.indexOf(currentInterval) + + GenericAlertDialog( + title = stringResource(R.string.set_sync_interval_title), + confirmButton = stringResource(android.R.string.ok) to onDismiss, + dismissButton = stringResource(android.R.string.cancel) to onDismiss, + onDismissRequest = onDismiss, + content = { + LazyColumn { + itemsIndexed(syncIntervalNames) { index, name -> + ListItem( + modifier = Modifier.clickable { + onSetSyncInterval(syncIntervalValues[index]) + }, + headlineContent = { Text(name) }, + trailingContent = { + Icon( + imageVector = if (currentIntervalIdx == index) + Icons.Filled.RadioButtonChecked + else + Icons.Filled.RadioButtonUnchecked, + contentDescription = null + ) + } + ) + } + } + } + ) +} + +@Preview +@Composable +fun SyncIntervalDialog_Preview() { + SyncIntervalDialog( + -1, // only manually + onSetSyncInterval = {}, + onDismiss = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/partials/TextFieldErrorLabel.kt b/app/src/main/java/at/bitfire/icsdroid/ui/partials/TextFieldErrorLabel.kt new file mode 100644 index 00000000..4f963960 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/partials/TextFieldErrorLabel.kt @@ -0,0 +1,26 @@ +package at.bitfire.icsdroid.ui.partials + +import androidx.compose.animation.AnimatedContent +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 + +@Composable +fun TextFieldErrorLabel(error: String?) { + AnimatedContent( + targetState = error, + label = "show/hide error" + ) { err -> + err?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 4.dp), + color = MaterialTheme.colorScheme.error + ) + } + } +} diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/reusable/SwitchRow.kt b/app/src/main/java/at/bitfire/icsdroid/ui/reusable/SwitchRow.kt deleted file mode 100644 index 76c62709..00000000 --- a/app/src/main/java/at/bitfire/icsdroid/ui/reusable/SwitchRow.kt +++ /dev/null @@ -1,69 +0,0 @@ -package at.bitfire.icsdroid.ui.reusable - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Switch -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider - -@Composable -fun SwitchRow( - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - text: String, - modifier: Modifier = Modifier, - enabled: Boolean = true -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - ) { - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - enabled = enabled - ) - Text( - text = text, - style = MaterialTheme.typography.caption, - modifier = Modifier - .weight(1f) - .clickable(enabled) { onCheckedChange(!checked) } - ) - } -} - -class SwitchRowPreview: PreviewParameterProvider { - data class SwitchRowPreviewData( - val checked: Boolean, - val enabled: Boolean - ) { - val text = "SwitchRow ${if (checked) "checked" else "unchecked"} ${if (enabled) "enabled" else "disabled"}" - } - - override val values: Sequence = sequenceOf( - SwitchRowPreviewData(checked = true, enabled = true), - SwitchRowPreviewData(checked = false, enabled = true), - SwitchRowPreviewData(checked = true, enabled = false), - SwitchRowPreviewData(checked = false, enabled = false), - ) -} - -@Preview -@Composable -fun SwitchRow_PreviewChecked( - @PreviewParameter(SwitchRowPreview::class) state: SwitchRowPreview.SwitchRowPreviewData -) { - SwitchRow( - checked = state.checked, - enabled = state.enabled, - onCheckedChange = {}, - text = state.text - ) -} diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/theme/Colors.kt b/app/src/main/java/at/bitfire/icsdroid/ui/theme/Colors.kt new file mode 100644 index 00000000..cafea60a --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/theme/Colors.kt @@ -0,0 +1,11 @@ +package at.bitfire.icsdroid.ui.theme + +import androidx.compose.ui.graphics.Color + +val lightblue = Color(0xff039be5) +val darkblue = Color(0xff01579b) +val red = Color(0xffff2200) + +val offwhite = Color(0xfffcfcff) +val lightgrey = Color(0xffdee3ea) +val nearlyBlack = Color(0xFFD1D1D1) diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/theme/Theme.kt b/app/src/main/java/at/bitfire/icsdroid/ui/theme/Theme.kt new file mode 100644 index 00000000..fbb43a30 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/theme/Theme.kt @@ -0,0 +1,133 @@ +package at.bitfire.icsdroid.ui.theme + +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import at.bitfire.icsdroid.Settings + +private val DarkColors = darkColorScheme( + primary = lightblue, + onPrimary = offwhite, + primaryContainer = lightblue, + onPrimaryContainer = offwhite, + secondary = lightblue, + onSecondary = offwhite, + secondaryContainer = lightblue, + onSecondaryContainer = offwhite, + tertiary = lightblue, + onTertiary = offwhite, + tertiaryContainer = lightblue, + onTertiaryContainer = offwhite, +) + +private val LightColors = lightColorScheme( + primary = lightblue, + onPrimary = offwhite, + primaryContainer = lightblue, + onPrimaryContainer = offwhite, + secondary = lightblue, + onSecondary = offwhite, + secondaryContainer = lightblue, + onSecondaryContainer = offwhite, + tertiary = lightblue, + onTertiary = offwhite, + tertiaryContainer = lightblue, + onTertiaryContainer = offwhite, + background = offwhite, + surfaceVariant = lightgrey, +) + + +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val context = LocalContext.current + + val colorScheme = if (darkTheme) + DarkColors + else + LightColors + + MaterialTheme(colorScheme = colorScheme) { + LaunchedEffect(darkTheme) { + (context as? AppCompatActivity)?.let { activity -> + val style = if (darkTheme) + SystemBarStyle.dark( + nearlyBlack.toArgb() + ) + else + SystemBarStyle.dark( + darkblue.toArgb() + ) + activity.enableEdgeToEdge( + statusBarStyle = style, + navigationBarStyle = style + ) + } ?: Log.e("AppTheme", "Context is not activity!") + } + + Box( + modifier = Modifier + // Required to make sure all paddings are correctly set + .systemBarsPadding() + .fillMaxSize() + ) { + content() + } + } +} + +/** + * Composes the given composable into the given activity. The content will become the root view of + * the given activity. + * This is roughly equivalent to calling [ComponentActivity.setContentView] with a ComposeView i.e.: + * ```kotlin + * setContentView( + * ComposeView(this).apply { + * setContent { + * MyComposableContent() + * } + * } + * ) + * ``` + * + * Then, applies [AppTheme] to the UI. + * + * @param parent The parent composition reference to coordinate scheduling of composition updates + * @param darkTheme Calculates whether the UI should be shown in light or dark theme. + * @param content A `@Composable` function declaring the UI contents + */ +fun ComponentActivity.setContentThemed( + parent: CompositionContext? = null, + darkTheme: @Composable () -> Boolean = { + val forceDarkTheme by Settings(this).forceDarkModeLive().observeAsState() + forceDarkTheme == true || isSystemInDarkTheme() + }, + content: @Composable () -> Unit +) { + setContent(parent) { + AppTheme(darkTheme = darkTheme()) { + content() + } + } +} diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/views/AddCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/views/AddCalendarActivity.kt new file mode 100644 index 00000000..34f11e8b --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/views/AddCalendarActivity.kt @@ -0,0 +1,398 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.ui.views + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import at.bitfire.icsdroid.Constants +import at.bitfire.icsdroid.HttpClient +import at.bitfire.icsdroid.HttpUtils +import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.calendar.LocalCalendar +import at.bitfire.icsdroid.model.CreateSubscriptionModel +import at.bitfire.icsdroid.model.CredentialsModel +import at.bitfire.icsdroid.model.SubscriptionSettingsModel +import at.bitfire.icsdroid.model.ValidationModel +import at.bitfire.icsdroid.ui.ResourceInfo +import at.bitfire.icsdroid.ui.partials.ExtendedTopAppBar +import at.bitfire.icsdroid.ui.theme.lightblue +import at.bitfire.icsdroid.ui.theme.setContentThemed +import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.net.URI +import java.net.URISyntaxException + +@OptIn(ExperimentalFoundationApi::class) +class AddCalendarActivity : AppCompatActivity() { + + companion object { + const val EXTRA_TITLE = "title" + const val EXTRA_COLOR = "color" + } + + private val subscriptionSettingsModel by viewModels() + private val credentialsModel by viewModels() + private val validationModel by viewModels() + private val subscriptionModel by viewModels() + + private val pickFile = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + if (uri != null) { + // keep the picked file accessible after the first sync and reboots + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + subscriptionSettingsModel.url.value = uri.toString() + } + } + + override fun onCreate(inState: Bundle?) { + super.onCreate(inState) + + if (inState == null) { + intent?.apply { + data?.let { uri -> + subscriptionSettingsModel.url.value = uri.toString() + } + getStringExtra(EXTRA_TITLE)?.let { + subscriptionSettingsModel.title.value = it + } + if (hasExtra(EXTRA_COLOR)) + subscriptionSettingsModel.color.value = + getIntExtra(EXTRA_COLOR, LocalCalendar.DEFAULT_COLOR) + } + } + + subscriptionModel.success.observe(this) { success -> + if (success) { + // success, show notification and close activity + Toast.makeText(this, getString(R.string.add_calendar_created), Toast.LENGTH_LONG).show() + + finish() + } + } + subscriptionModel.errorMessage.observe(this) { message -> + message?.let { Toast.makeText(this, it, Toast.LENGTH_LONG).show() } + } + + setContentThemed { + val pagerState = rememberPagerState { 2 } + + val url: String? by subscriptionSettingsModel.url.observeAsState(null) + val urlError: String? by subscriptionSettingsModel.urlError.observeAsState(null) + val supportsAuthentication: Boolean by subscriptionSettingsModel.supportsAuthentication.observeAsState(false) + val title by subscriptionSettingsModel.title.observeAsState(null) + val color by subscriptionSettingsModel.color.observeAsState(null) + val ignoreAlerts by subscriptionSettingsModel.ignoreAlerts.observeAsState(false) + val defaultAlarmMinutes by subscriptionSettingsModel.defaultAlarmMinutes.observeAsState(null) + val defaultAllDayAlarmMinutes by subscriptionSettingsModel.defaultAllDayAlarmMinutes.observeAsState(null) + + val requiresAuth: Boolean by credentialsModel.requiresAuth.observeAsState(false) + val username: String? by credentialsModel.username.observeAsState(null) + val password: String? by credentialsModel.password.observeAsState(null) + val isInsecure: Boolean by credentialsModel.isInsecure.observeAsState(false) + + val isVerifyingUrl: Boolean by validationModel.isVerifyingUrl.observeAsState(false) + val validationResult: ResourceInfo? by validationModel.result.observeAsState(null) + + val isCreating: Boolean by subscriptionModel.isCreating.observeAsState(false) + + var showNextButton by remember { mutableStateOf(false) } + + // Receive updates for the URL introduction page + LaunchedEffect(url, requiresAuth, username, password, isVerifyingUrl) { + if (isVerifyingUrl) { + showNextButton = true + return@LaunchedEffect + } + + val uri = validateUri() + val authOK = + if (requiresAuth) + !username.isNullOrEmpty() && !password.isNullOrEmpty() + else + true + showNextButton = uri != null && authOK + } + + // Receive updates for the Details page + LaunchedEffect(title, color, ignoreAlerts, defaultAlarmMinutes, defaultAllDayAlarmMinutes) { + showNextButton = !subscriptionSettingsModel.title.value.isNullOrBlank() + } + + LaunchedEffect(validationResult) { + Log.i("AddCalendarActivity", "Validation result updated: $validationResult") + if (validationResult == null || validationResult?.exception != null) return@LaunchedEffect + val info = validationResult!! + + // When a result has been obtained, and it's neither null nor has an exception, + // clean the subscriptionSettingsModel, and move the pager to the next page + subscriptionSettingsModel.url.value = info.uri.toString() + + if (subscriptionSettingsModel.color.value == null) + subscriptionSettingsModel.color.value = + info.calendarColor ?: lightblue.toArgb() + + if (subscriptionSettingsModel.title.value.isNullOrBlank()) + subscriptionSettingsModel.title.value = + info.calendarName ?: info.uri.toString() + + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + + Scaffold( + topBar = { AddCalendarTopAppBar(pagerState, showNextButton, isVerifyingUrl, isCreating) } + ) { paddingValues -> + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { page -> + when (page) { + 0 -> EnterUrlComposable( + requiresAuth = requiresAuth, + onRequiresAuthChange = credentialsModel.requiresAuth::setValue, + username = username, + onUsernameChange = credentialsModel.username::setValue, + password = password, + onPasswordChange = credentialsModel.password::setValue, + isInsecure = isInsecure, + url = url, + onUrlChange = subscriptionSettingsModel.url::setValue, + urlError = urlError, + supportsAuthentication = supportsAuthentication, + isVerifyingUrl = isVerifyingUrl, + validationResult = validationResult, + onValidationResultDismiss = { validationModel.result.value = null }, + onPickFileRequested = { pickFile.launch(arrayOf("text/calendar")) }, + onSubmit = { onNextRequested(1) } + ) + + 1 -> SubscriptionSettingsComposable( + url = url, + title = title, + titleChanged = subscriptionSettingsModel.title::setValue, + color = color, + colorChanged = subscriptionSettingsModel.color::setValue, + ignoreAlerts = ignoreAlerts, + ignoreAlertsChanged = subscriptionSettingsModel.ignoreAlerts::setValue, + defaultAlarmMinutes = defaultAlarmMinutes, + defaultAlarmMinutesChanged = { + subscriptionSettingsModel.defaultAlarmMinutes.postValue( + it.toLongOrNull() + ) + }, + defaultAllDayAlarmMinutes = defaultAllDayAlarmMinutes, + defaultAllDayAlarmMinutesChanged = { + subscriptionSettingsModel.defaultAllDayAlarmMinutes.postValue( + it.toLongOrNull() + ) + }, + isCreating = isCreating, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) + } + } + } + } + } + + override fun onPause() { + super.onPause() + HttpClient.setForeground(false) + } + + override fun onResume() { + super.onResume() + HttpClient.setForeground(true) + } + + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun AddCalendarTopAppBar( + pagerState: PagerState, + showNextButton: Boolean, + isVerifyingUrl: Boolean, + isCreating: Boolean + ) { + val scope = rememberCoroutineScope() + ExtendedTopAppBar( + navigationIcon = { + IconButton( + onClick = { + // If first page, close activity + if (pagerState.currentPage <= 0) finish() + // otherwise, go back a page + else scope.launch { + // Needed for non-first-time validations to trigger following validation result updates + validationModel.result.postValue(null) + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } + } + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null) + } + }, + title = { Text(text = stringResource(R.string.activity_add_calendar)) }, + actions = { + AnimatedVisibility(visible = showNextButton) { + IconButton( + onClick = { onNextRequested(pagerState.currentPage) }, + enabled = !isVerifyingUrl && !isCreating + ) { + Icon(Icons.AutoMirrored.Filled.ArrowForward, null) + } + } + } + ) + } + + + private fun onNextRequested(page: Int) { + when (page) { + // First page (Enter Url) + 0 -> { + // flush the credentials if auth toggle is disabled + if (credentialsModel.requiresAuth.value != true) { + credentialsModel.username.value = null + credentialsModel.password.value = null + } + + val uri: Uri? = subscriptionSettingsModel.url.value?.let(Uri::parse) + val authenticate = credentialsModel.requiresAuth.value ?: false + + if (uri != null) { + validationModel.validate( + uri, + if (authenticate) credentialsModel.username.value else null, + if (authenticate) credentialsModel.password.value else null + ) + } + } + // Second page (details and confirm) + 1 -> { + subscriptionModel.create(subscriptionSettingsModel, credentialsModel) + } + } + } + + + /* dynamic changes */ + + private fun validateUri(): Uri? { + var errorMsg: String? = null + + var uri: Uri + try { + try { + uri = Uri.parse(subscriptionSettingsModel.url.value ?: return null) + } catch (e: URISyntaxException) { + Log.d(Constants.TAG, "Invalid URL", e) + errorMsg = e.localizedMessage + return null + } + + Log.i(Constants.TAG, uri.toString()) + + if (uri.scheme.equals("webcal", true)) { + uri = uri.buildUpon().scheme("http").build() + subscriptionSettingsModel.url.value = uri.toString() + return null + } else if (uri.scheme.equals("webcals", true)) { + uri = uri.buildUpon().scheme("https").build() + subscriptionSettingsModel.url.value = uri.toString() + return null + } + + val supportsAuthenticate = HttpUtils.supportsAuthentication(uri) + subscriptionSettingsModel.supportsAuthentication.value = supportsAuthenticate + when (uri.scheme?.lowercase()) { + "content" -> { + // SAF file, no need for auth + } + + "http", "https" -> { + // check whether the URL is valid + try { + uri.toString().toHttpUrl() + } catch (e: IllegalArgumentException) { + Log.w(Constants.TAG, "Invalid URI", e) + errorMsg = e.localizedMessage + return null + } + + // extract user name and password from URL + uri.userInfo?.let { userInfo -> + val credentials = userInfo.split(':') + credentialsModel.requiresAuth.value = true + credentialsModel.username.value = credentials.elementAtOrNull(0) + credentialsModel.password.value = credentials.elementAtOrNull(1) + + val urlWithoutPassword = + URI(uri.scheme, null, uri.host, uri.port, uri.path, uri.query, null) + subscriptionSettingsModel.url.value = urlWithoutPassword.toString() + return null + } + } + + else -> { + errorMsg = getString(R.string.add_calendar_need_valid_uri) + return null + } + } + + // warn if auth. required and not using HTTPS + credentialsModel.isInsecure.value = + credentialsModel.requiresAuth.value == true && !uri.scheme.equals("https", true) + } finally { + subscriptionSettingsModel.urlError.value = errorMsg + } + return uri + } + +} diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/views/CalendarListActivity.kt similarity index 66% rename from app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt rename to app/src/main/java/at/bitfire/icsdroid/ui/views/CalendarListActivity.kt index 71950bdd..9d8ef901 100644 --- a/app/src/main/java/at/bitfire/icsdroid/ui/CalendarListActivity.kt +++ b/app/src/main/java/at/bitfire/icsdroid/ui/views/CalendarListActivity.kt @@ -2,54 +2,80 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.icsdroid.ui +package at.bitfire.icsdroid.ui.views import android.app.Application import android.content.Intent import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.PowerManager -import android.view.* -import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.core.content.getSystemService import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import androidx.work.WorkInfo -import at.bitfire.icsdroid.* +import at.bitfire.icsdroid.AppAccount +import at.bitfire.icsdroid.BuildConfig +import at.bitfire.icsdroid.PermissionUtils import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.Settings +import at.bitfire.icsdroid.SyncWorker +import at.bitfire.icsdroid.UriUtils import at.bitfire.icsdroid.db.AppDatabase -import at.bitfire.icsdroid.ui.dialog.SyncIntervalDialog -import at.bitfire.icsdroid.ui.list.CalendarListItem -import at.bitfire.icsdroid.ui.reusable.ActionCard -import com.google.accompanist.themeadapter.material.MdcTheme +import at.bitfire.icsdroid.service.ComposableStartupService +import at.bitfire.icsdroid.ui.InfoActivity +import at.bitfire.icsdroid.ui.partials.ActionCard +import at.bitfire.icsdroid.ui.partials.CalendarListItem +import at.bitfire.icsdroid.ui.partials.ExtendedTopAppBar +import at.bitfire.icsdroid.ui.partials.SyncIntervalDialog +import at.bitfire.icsdroid.ui.theme.setContentThemed import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.util.* +import java.util.ServiceLoader @OptIn(ExperimentalFoundationApi::class) class CalendarListActivity: AppCompatActivity() { @@ -73,6 +99,7 @@ class CalendarListActivity: AppCompatActivity() { private lateinit var requestNotificationPermission: () -> Unit + @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -93,63 +120,68 @@ class CalendarListActivity: AppCompatActivity() { if (requestPermissions && !PermissionUtils.haveCalendarPermissions(this)) requestCalendarPermissions() - // startup fragments - if (savedInstanceState == null) - ServiceLoader - .load(StartupFragment::class.java) - .forEach { it.initialize(this) } - - setContent { - MdcTheme { - Scaffold( - floatingActionButton = { - FloatingActionButton( - onClick = { - // Launch the Subscription add Activity - startActivity(Intent(this, AddCalendarActivity::class.java)) - } - ) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = stringResource(R.string.activity_add_calendar) - ) + // Init and collect all ComposableStartupServices + val compStartupServices = ServiceLoader.load(ComposableStartupService::class.java) + .onEach { it.initialize(this) } + + setContentThemed { + compStartupServices.forEach { service -> + val show: Boolean by service.shouldShow().observeAsState(false) + if (show) service.Content() + } + + Scaffold( + floatingActionButton = { + FloatingActionButton( + onClick = { + // Launch the Subscription add Activity + startActivity(Intent(this, AddCalendarActivity::class.java)) } - }, - topBar = { - TopAppBar( - title = { - Text(stringResource(R.string.title_activity_calendar_list)) - }, - actions = { - ActionOverflowMenu() - } + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(R.string.activity_add_calendar) ) } - ) { paddingValues -> - ActivityContent(paddingValues) + }, + topBar = { + ExtendedTopAppBar( + title = { + Text(stringResource(R.string.title_activity_calendar_list)) + }, + actions = { + ActionOverflowMenu() + } + ) } + ) { paddingValues -> + ActivityContent(paddingValues) } } } override fun onResume() { super.onResume() - model.checkSyncSettings() } /* UI components */ - @OptIn(ExperimentalMaterialApi::class) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ActivityContent(paddingValues: PaddingValues) { val context = LocalContext.current - val isRefreshing by model.isRefreshing.observeAsState(initial = true) - val pullRefreshState = rememberPullRefreshState( - refreshing = isRefreshing, - onRefresh = ::onRefreshRequested - ) + val syncing by model.isRefreshing.observeAsState(initial = true) + val pullRefreshState = rememberPullToRefreshState() + if (pullRefreshState.isRefreshing) LaunchedEffect(true) { + pullRefreshState.startRefresh() + onRefreshRequested() + } + if (!syncing) LaunchedEffect(true) { + delay(1000) // So we can see the spinner shortly, when sync finishes super fast + pullRefreshState.endRefresh() + } val subscriptions by model.subscriptions.observeAsState() @@ -160,8 +192,14 @@ class CalendarListActivity: AppCompatActivity() { Box( modifier = Modifier .padding(paddingValues) - .pullRefresh(pullRefreshState) + .nestedScroll(pullRefreshState.nestedScrollConnection) ) { + PullToRefreshContainer( + modifier = Modifier.align(Alignment.TopCenter).zIndex(1f), + state = pullRefreshState, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) LazyColumn(Modifier.fillMaxSize()) { // Calendar permission card if (askForCalendarPermission) { @@ -198,7 +236,7 @@ class CalendarListActivity: AppCompatActivity() { } // Whitelisting card - if (askForWhitelisting && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (askForWhitelisting) { item(key = "battery-whitelisting") { ActionCard( title = stringResource(R.string.calendar_list_battery_whitelist_title), @@ -219,7 +257,7 @@ class CalendarListActivity: AppCompatActivity() { item(key = "empty") { Text( text = stringResource(R.string.calendar_list_empty_info), - style = MaterialTheme.typography.body1, + style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() @@ -237,12 +275,6 @@ class CalendarListActivity: AppCompatActivity() { }) } } - - PullRefreshIndicator( - refreshing = isRefreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter) - ) } } @@ -274,60 +306,56 @@ class CalendarListActivity: AppCompatActivity() { onDismissRequest = { showMenu = false } ) { DropdownMenuItem( + text = { Text(stringResource(R.string.calendar_list_set_sync_interval)) }, onClick = { showMenu = false showSyncIntervalDialog = true } - ) { - Text(stringResource(R.string.calendar_list_set_sync_interval)) - } + ) DropdownMenuItem( + text = { Text(stringResource(R.string.calendar_list_synchronize)) }, onClick = { showMenu = false onRefreshRequested() } - ) { - Text(stringResource(R.string.calendar_list_synchronize)) - } + ) DropdownMenuItem( + text = { + val forceDarkMode by settings.forceDarkModeLive().observeAsState(false) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.settings_force_dark_theme)) + Checkbox( + checked = forceDarkMode, + onCheckedChange = { onToggleDarkMode() } + ) + } + }, onClick = { showMenu = false onToggleDarkMode() } - ) { - val forceDarkMode by settings.forceDarkModeLive().observeAsState(false) - - Text(stringResource(R.string.settings_force_dark_theme)) - Checkbox( - checked = forceDarkMode, - onCheckedChange = { onToggleDarkMode() } - ) - } + ) DropdownMenuItem( + text = { Text(stringResource(R.string.calendar_list_privacy_policy)) }, onClick = { showMenu = false UriUtils.launchUri(context, Uri.parse(PRIVACY_POLICY_URL)) } - ) { - Text(stringResource(R.string.calendar_list_privacy_policy)) - } + ) DropdownMenuItem( + text = { Text(stringResource(R.string.calendar_list_info)) }, onClick = { showMenu = false startActivity(Intent(context, InfoActivity::class.java)) } - ) { - Text(stringResource(R.string.calendar_list_info)) - } + ) } } /* actions */ - private fun onRefreshRequested() { - SyncWorker.run(this, true) - } + private fun onRefreshRequested() = SyncWorker.run(this, true) private fun onToggleDarkMode() { val settings = Settings(this) @@ -372,18 +400,13 @@ class CalendarListActivity: AppCompatActivity() { val haveCalendarPermission = PermissionUtils.haveCalendarPermissions(getApplication()) askForCalendarPermission.postValue(!haveCalendarPermission) - val shouldWhitelistApp = if (Build.VERSION.SDK_INT >= 23) { - val powerManager = getApplication().getSystemService() - val isIgnoringBatteryOptimizations = powerManager?.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) + val powerManager = getApplication().getSystemService() + val isIgnoringBatteryOptimizations = powerManager?.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) - val syncInterval = AppAccount.syncInterval(getApplication()) + val syncInterval = AppAccount.syncInterval(getApplication()) - // If not ignoring battery optimizations, and sync interval is less than a day - isIgnoringBatteryOptimizations == false && syncInterval != AppAccount.SYNC_INTERVAL_MANUALLY && syncInterval < 86400 - } else { - // If using Android < 6, this is not necessary - false - } + // If not ignoring battery optimizations, and sync interval is less than a day + val shouldWhitelistApp = isIgnoringBatteryOptimizations == false && syncInterval != AppAccount.SYNC_INTERVAL_MANUALLY && syncInterval < 86400 askForWhitelisting.postValue(shouldWhitelistApp) } diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/views/CredentialsComposable.kt b/app/src/main/java/at/bitfire/icsdroid/ui/views/CredentialsComposable.kt new file mode 100644 index 00000000..2fa7d18b --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/views/CredentialsComposable.kt @@ -0,0 +1,161 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.ui.views + +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.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import at.bitfire.icsdroid.R + +@Composable +fun LoginCredentialsComposable( + requiresAuth: Boolean, + username: String? = null, + password: String? = null, + onRequiresAuthChange: (Boolean) -> Unit, + onUsernameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit +) { + val usernameError = if (username?.isBlank() == true) + stringResource(R.string.edit_calendar_need_username) + else null + val passwordError = if (username?.isBlank() == true) + stringResource(R.string.edit_calendar_need_password) + else null + Column( + Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.add_calendar_requires_authentication), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = requiresAuth, + onCheckedChange = onRequiresAuthChange, + ) + } + if (requiresAuth) { + OutlinedTextField( + value = username ?: "", + onValueChange = onUsernameChange, + label = { Text(stringResource(R.string.add_calendar_user_name)) }, + supportingText = { Text(usernameError ?: stringResource(R.string.required_annotation)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + isError = usernameError != null, + modifier = Modifier.fillMaxWidth() + ) + + PasswordTextField( + password = password ?: "", + labelText = stringResource(R.string.add_calendar_password), + supportingText = passwordError ?: stringResource(R.string.required_annotation), + isError = passwordError != null, + errorText = passwordError, + onPasswordChange = onPasswordChange + ) + } + } +} + +@Composable +fun PasswordTextField( + password: String, + labelText: String = "", + supportingText: String = "", + enabled: Boolean = true, + isError: Boolean = false, + errorText: String? = null, + onPasswordChange: (String) -> Unit +) { + var passwordVisible by remember { mutableStateOf(false) } + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + label = { Text(labelText) }, + supportingText = { Text(errorText ?: supportingText) }, + isError = isError, + singleLine = true, + enabled = enabled, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + if (passwordVisible) + Icon(Icons.Rounded.VisibilityOff, stringResource(R.string.add_calendar_password_hide)) + else + Icon(Icons.Rounded.Visibility, stringResource(R.string.add_calendar_password_show)) + } + }, + modifier = Modifier.fillMaxWidth() + ) +} + +@Composable +@Preview +fun LoginCredentialsComposable_Preview() { + LoginCredentialsComposable( + requiresAuth = true, + username = "Demo user", + password = "demo password", + onRequiresAuthChange = {}, + onUsernameChange = {}, + onPasswordChange = {} + ) +} + +@Composable +@Preview +fun LoginCredentialsComposable_Preview_Empty() { + LoginCredentialsComposable( + requiresAuth = true, + username = null, + password = null, + onRequiresAuthChange = {}, + onUsernameChange = {}, + onPasswordChange = {} + ) +} + +@Composable +@Preview +fun LoginCredentialsComposable_Preview_Error() { + LoginCredentialsComposable( + requiresAuth = true, + username = "", + password = "", + onRequiresAuthChange = {}, + onUsernameChange = {}, + onPasswordChange = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/views/EditCalendarActivity.kt b/app/src/main/java/at/bitfire/icsdroid/ui/views/EditCalendarActivity.kt new file mode 100644 index 00000000..6712e9a0 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/views/EditCalendarActivity.kt @@ -0,0 +1,345 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.ui.views + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.app.ShareCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.db.dao.SubscriptionsDao +import at.bitfire.icsdroid.db.entity.Credential +import at.bitfire.icsdroid.db.entity.Subscription +import at.bitfire.icsdroid.model.CredentialsModel +import at.bitfire.icsdroid.model.EditSubscriptionModel +import at.bitfire.icsdroid.model.SubscriptionSettingsModel +import at.bitfire.icsdroid.ui.partials.AlertDialog +import at.bitfire.icsdroid.ui.partials.ExtendedTopAppBar +import at.bitfire.icsdroid.ui.partials.GenericAlertDialog +import at.bitfire.icsdroid.ui.theme.setContentThemed + +class EditCalendarActivity: AppCompatActivity() { + + companion object { + const val EXTRA_SUBSCRIPTION_ID = "subscriptionId" + const val EXTRA_ERROR_MESSAGE = "errorMessage" + const val EXTRA_THROWABLE = "errorThrowable" + } + + private val subscriptionSettingsModel by viewModels() + private var initialSubscription: Subscription? = null + private val credentialsModel by viewModels() + private var initialCredential: Credential? = null + private var initialRequiresAuthValue: Boolean? = null + + // Whether user made changes are legal + private val inputValid: LiveData by lazy { + object : MediatorLiveData() { + init { + addSource(subscriptionSettingsModel.title) { validate() } + addSource(credentialsModel.requiresAuth) { validate() } + addSource(credentialsModel.username) { validate() } + addSource(credentialsModel.password) { validate() } + } + fun validate() { + val titleOK = !subscriptionSettingsModel.title.value.isNullOrBlank() + val authOK = credentialsModel.run { + if (requiresAuth.value == true) + !username.value.isNullOrBlank() && !password.value.isNullOrBlank() + else + true + } + value = titleOK && authOK + } + } + } + + // Whether unsaved changes exist + private val modelsDirty: MutableLiveData by lazy { + object : MediatorLiveData() { + init { + addSource(subscriptionSettingsModel.title) { value = subscriptionDirty() } + addSource(subscriptionSettingsModel.color) { value = subscriptionDirty() } + addSource(subscriptionSettingsModel.ignoreAlerts) { value = subscriptionDirty() } + addSource(subscriptionSettingsModel.defaultAlarmMinutes) { value = subscriptionDirty() } + addSource(subscriptionSettingsModel.defaultAllDayAlarmMinutes) { value = subscriptionDirty() } + addSource(credentialsModel.requiresAuth) { value = credentialDirty() } + addSource(credentialsModel.username) { value = credentialDirty() } + addSource(credentialsModel.password) { value = credentialDirty() } + } + fun subscriptionDirty() = initialSubscription?.let { + !subscriptionSettingsModel.equalsSubscription(it) + } ?: false + fun credentialDirty() = + initialRequiresAuthValue != credentialsModel.requiresAuth.value || + initialCredential?.let { + !credentialsModel.equalsCredential(it) + } ?: false + } + } + + private val model by viewModels { + object: ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val subscriptionId = intent.getLongExtra(EXTRA_SUBSCRIPTION_ID, -1) + return EditSubscriptionModel(application, subscriptionId) as T + } + } + } + + override fun onCreate(inState: Bundle?) { + super.onCreate(inState) + + // Initialise view models and save their initial state + model.subscriptionWithCredential.observe(this) { data -> + if (data != null) + onSubscriptionLoaded(data) + } + + // handle status changes + model.successMessage.observe(this) { message -> + if (message != null) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + finish() + } + } + + setContentThemed { + // show error message from calling intent, if available + if (inState == null) + intent.getStringExtra(EXTRA_ERROR_MESSAGE)?.let { error -> + AlertDialog(error, intent.getSerializableExtra(EXTRA_THROWABLE) as? Throwable) {} + } + EditCalendarComposable() + } + } + + /** + * Initialise view models and remember their initial state + */ + private fun onSubscriptionLoaded(subscriptionWithCredential: SubscriptionsDao.SubscriptionWithCredential) { + val subscription = subscriptionWithCredential.subscription + + subscriptionSettingsModel.url.value = subscription.url.toString() + subscription.displayName.let { + subscriptionSettingsModel.title.value = it + } + subscription.color.let { + subscriptionSettingsModel.color.value = it + } + subscription.ignoreEmbeddedAlerts.let { + subscriptionSettingsModel.ignoreAlerts.postValue(it) + } + subscription.defaultAlarmMinutes.let { + subscriptionSettingsModel.defaultAlarmMinutes.postValue(it) + } + subscription.defaultAllDayAlarmMinutes.let { + subscriptionSettingsModel.defaultAllDayAlarmMinutes.postValue(it) + } + + val credential = subscriptionWithCredential.credential + val requiresAuth = credential != null + credentialsModel.requiresAuth.value = requiresAuth + + if (credential != null) { + credential.username.let { username -> + credentialsModel.username.value = username + } + credential.password.let { password -> + credentialsModel.password.value = password + } + } + + // Save state, before user makes changes + initialSubscription = subscription + initialCredential = credential + initialRequiresAuthValue = credentialsModel.requiresAuth.value + } + + + /* user actions */ + + private fun onSave() = model.updateSubscription(subscriptionSettingsModel, credentialsModel) + + private fun onDelete() = model.removeSubscription() + + private fun onShare() = model.subscriptionWithCredential.value?.let { (subscription, _) -> + ShareCompat.IntentBuilder(this) + .setSubject(subscription.displayName) + .setText(subscription.url.toString()) + .setType("text/plain") + .setChooserTitle(R.string.edit_calendar_send_url) + .startChooser() + } + + /* Composables */ + + @Composable + private fun EditCalendarComposable() { + val url by subscriptionSettingsModel.url.observeAsState("") + val title by subscriptionSettingsModel.title.observeAsState("") + val color by subscriptionSettingsModel.color.observeAsState() + val ignoreAlerts by subscriptionSettingsModel.ignoreAlerts.observeAsState(false) + val defaultAlarmMinutes by subscriptionSettingsModel.defaultAlarmMinutes.observeAsState() + val defaultAllDayAlarmMinutes by subscriptionSettingsModel.defaultAllDayAlarmMinutes.observeAsState() + val inputValid by inputValid.observeAsState(false) + val modelsDirty by modelsDirty.observeAsState(false) + Scaffold( + topBar = { AppBarComposable(inputValid, modelsDirty) } + ) { paddingValues -> + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(paddingValues) + .padding(16.dp) + ) { + SubscriptionSettingsComposable( + url = url, + title = title, + titleChanged = { subscriptionSettingsModel.title.postValue(it) }, + color = color, + colorChanged = subscriptionSettingsModel.color::postValue, + ignoreAlerts = ignoreAlerts, + ignoreAlertsChanged = { subscriptionSettingsModel.ignoreAlerts.postValue(it) }, + defaultAlarmMinutes = defaultAlarmMinutes, + defaultAlarmMinutesChanged = { + subscriptionSettingsModel.defaultAlarmMinutes.postValue( + it.toLongOrNull() + ) + }, + defaultAllDayAlarmMinutes = defaultAllDayAlarmMinutes, + defaultAllDayAlarmMinutesChanged = { + subscriptionSettingsModel.defaultAllDayAlarmMinutes.postValue( + it.toLongOrNull() + ) + }, + isCreating = false, + modifier = Modifier.fillMaxWidth() + ) + val supportsAuthentication: Boolean by subscriptionSettingsModel.supportsAuthentication.observeAsState( + false + ) + val requiresAuth: Boolean by credentialsModel.requiresAuth.observeAsState(false) + val username: String? by credentialsModel.username.observeAsState(null) + val password: String? by credentialsModel.password.observeAsState(null) + AnimatedVisibility(visible = supportsAuthentication) { + LoginCredentialsComposable( + requiresAuth, + username, + password, + onRequiresAuthChange = credentialsModel.requiresAuth::setValue, + onUsernameChange = credentialsModel.username::setValue, + onPasswordChange = credentialsModel.password::setValue + ) + } + } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun AppBarComposable(valid: Boolean, modelsDirty: Boolean) { + var openDeleteDialog by remember { mutableStateOf(false) } + if (openDeleteDialog) + GenericAlertDialog( + content = { Text(stringResource(R.string.edit_calendar_really_delete)) }, + confirmButton = stringResource(R.string.edit_calendar_delete) to { + onDelete() + openDeleteDialog = false + }, + dismissButton = stringResource(R.string.edit_calendar_cancel) to { + openDeleteDialog = false + }, + ) { openDeleteDialog = false } + var openSaveDismissDialog by remember { mutableStateOf(false) } + if (openSaveDismissDialog) { + GenericAlertDialog( + content = { Text(text = if (valid) + stringResource(R.string.edit_calendar_unsaved_changes) + else + stringResource(R.string.edit_calendar_need_valid_credentials) + ) }, + confirmButton = if (valid) stringResource(R.string.edit_calendar_save) to { + onSave() + openSaveDismissDialog = false + } else stringResource(R.string.edit_calendar_edit) to { + openSaveDismissDialog = false + }, + dismissButton = stringResource(R.string.edit_calendar_dismiss) to ::finish + ) { openSaveDismissDialog = false } + } + ExtendedTopAppBar( + navigationIcon = { + IconButton( + onClick = { + if (modelsDirty) + openSaveDismissDialog = true + else + finish() + } + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null) + } + }, + title = { Text(text = stringResource(R.string.activity_edit_calendar)) }, + actions = { + IconButton(onClick = { onShare() }) { + Icon( + Icons.Filled.Share, + stringResource(R.string.edit_calendar_send_url) + ) + } + IconButton(onClick = { openDeleteDialog = true }) { + Icon(Icons.Filled.Delete, stringResource(R.string.edit_calendar_delete)) + } + AnimatedVisibility(visible = valid && modelsDirty) { + IconButton(onClick = { onSave() }) { + Icon(Icons.Filled.Check, stringResource(R.string.edit_calendar_save)) + } + } + } + ) + } + + @Preview + @Composable + fun TopBarComposable_Preview() { + AppBarComposable(valid = true, modelsDirty = true) + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/views/EnterUrlComposable.kt b/app/src/main/java/at/bitfire/icsdroid/ui/views/EnterUrlComposable.kt new file mode 100644 index 00000000..1c7a06f7 --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/views/EnterUrlComposable.kt @@ -0,0 +1,203 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.ui.views + +import androidx.compose.animation.AnimatedVisibility +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.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.ui.ResourceInfo +import at.bitfire.icsdroid.ui.partials.AlertDialog + +@Composable +fun EnterUrlComposable( + requiresAuth: Boolean, + onRequiresAuthChange: (Boolean) -> Unit, + username: String?, + onUsernameChange: (String) -> Unit, + password: String?, + onPasswordChange: (String) -> Unit, + isInsecure: Boolean, + url: String?, + onUrlChange: (String) -> Unit, + urlError: String?, + supportsAuthentication: Boolean, + isVerifyingUrl: Boolean, + validationResult: ResourceInfo?, + onValidationResultDismiss: () -> Unit, + onPickFileRequested: () -> Unit, + onSubmit: () -> Unit +) { + val context = LocalContext.current + + validationResult?.exception?.let { exception -> + val errorMessage = exception.localizedMessage ?: exception.message ?: exception.toString() + AlertDialog( + errorMessage, exception, onValidationResultDismiss + ) + } + + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(isVerifyingUrl) { + if (isVerifyingUrl) { + snackbarHostState.showSnackbar( + context.getString(R.string.add_calendar_validating), + duration = SnackbarDuration.Indefinite + ) + } else { + snackbarHostState.currentSnackbarData?.dismiss() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + ) { + // Instead of adding vertical padding to column, use spacer so that if content is + // scrolled, it is not spaced + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.add_calendar_url_text), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth() + ) + + TextField( + value = url ?: "", + onValueChange = onUrlChange, + modifier = Modifier + .fillMaxWidth() + .padding(end = 16.dp), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go + ), + keyboardActions = KeyboardActions { onSubmit() }, + maxLines = 1, + singleLine = true, + placeholder = { Text(stringResource(R.string.add_calendar_url_sample)) }, + isError = urlError != null, + enabled = !isVerifyingUrl + ) + AnimatedVisibility(visible = urlError != null) { + Text( + text = urlError ?: "", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodySmall + ) + } + + Text( + text = stringResource(R.string.add_calendar_pick_file_text), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) + + TextButton( + onClick = onPickFileRequested, + modifier = Modifier.padding(vertical = 15.dp), + enabled = !isVerifyingUrl + ) { + Text(stringResource(R.string.add_calendar_pick_file)) + } + + AnimatedVisibility( + visible = isInsecure, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row(Modifier.fillMaxWidth()) { + Icon(imageVector = Icons.Rounded.Warning, contentDescription = null) + + Text( + text = stringResource(R.string.add_calendar_authentication_without_https_warning), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth() + ) + } + } + + AnimatedVisibility(visible = supportsAuthentication) { + LoginCredentialsComposable( + requiresAuth, + username, + password, + onRequiresAuthChange, + onUsernameChange, + onPasswordChange + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Preview +@Composable +fun EnterUrlComposable_Preview() { + EnterUrlComposable( + requiresAuth = true, + onRequiresAuthChange = {}, + username = "previewUser", + onUsernameChange = {}, + password = "previewUserPassword", + onPasswordChange = {}, + isInsecure = true, + url = "http://previewUrl.com/calendarfile.ics", + onUrlChange = {}, + urlError = "", + supportsAuthentication = true, + isVerifyingUrl = true, + validationResult = null, + onValidationResultDismiss = {}, + onPickFileRequested = {}, + onSubmit = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/icsdroid/ui/views/SubscriptionSettingsComposable.kt b/app/src/main/java/at/bitfire/icsdroid/ui/views/SubscriptionSettingsComposable.kt new file mode 100644 index 00000000..b1c89ccb --- /dev/null +++ b/app/src/main/java/at/bitfire/icsdroid/ui/views/SubscriptionSettingsComposable.kt @@ -0,0 +1,175 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.icsdroid.ui.views + +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.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material3.Card +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.TextField +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import at.bitfire.icsdroid.R +import at.bitfire.icsdroid.calendar.LocalCalendar +import at.bitfire.icsdroid.ui.partials.ColorPickerDialog +import at.bitfire.icsdroid.ui.partials.SwitchSetting + +@Composable +fun SubscriptionSettingsComposable( + url: String?, + title: String?, + titleChanged: (String) -> Unit, + color: Int?, + colorChanged: (Int) -> Unit, + ignoreAlerts: Boolean, + ignoreAlertsChanged: (Boolean) -> Unit, + defaultAlarmMinutes: Long?, + defaultAlarmMinutesChanged: (String) -> Unit, + defaultAllDayAlarmMinutes: Long?, + defaultAllDayAlarmMinutesChanged: (String) -> Unit, + isCreating: Boolean, + modifier: Modifier = Modifier +) { + Column(modifier) { + + // Title + Text( + text = stringResource(R.string.add_calendar_title), + style = MaterialTheme.typography.headlineSmall, + ) + + // Name and color card + Card ( + modifier = Modifier.padding(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Column( + Modifier.weight(5f) + ) { + Text( + modifier = Modifier.padding(horizontal = 14.dp), + text = url ?: "", + color = Color.Gray, + style = MaterialTheme.typography.bodyMedium, + ) + TextField( + value = title ?: "", + onValueChange = titleChanged, + label = { Text(stringResource(R.string.add_calendar_title_hint)) }, + singleLine = true, + enabled = !isCreating + ) + } + var changeColorDialogOpen by remember { mutableStateOf(false) } + IconButton( + onClick = { changeColorDialogOpen = true }, + modifier = Modifier + .weight(1f) + .size(48.dp) + .padding(start = 8.dp)) { + Icon( + imageVector = Icons.Rounded.Circle, + contentDescription = stringResource(R.string.add_calendar_pick_color), + tint = color?.let { Color(it) } ?: Color.Unspecified, + modifier = Modifier + .size(48.dp) + ) + } + // Color picker dialog + if (changeColorDialogOpen) + ColorPickerDialog( + initialColor = color ?: LocalCalendar.DEFAULT_COLOR, + onSelectColor = colorChanged, + onDialogDismissed = { changeColorDialogOpen = false } + ) + } + } + + Spacer(modifier = Modifier.padding(12.dp)) + + // Alarms + Text( + text = stringResource(R.string.add_calendar_alarms_title), + style = MaterialTheme.typography.headlineSmall, + ) + + // Ignore existing alarms + SwitchSetting( + title = stringResource(R.string.add_calendar_alarms_ignore_title), + description = stringResource(R.string.add_calendar_alarms_ignore_description), + checked = ignoreAlerts, + onCheckedChange = ignoreAlertsChanged + ) + + Spacer(modifier = Modifier.padding(12.dp)) + + // Default Alarm + Text( + text = stringResource(R.string.default_alarm_dialog_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.default_alarm_dialog_message), + color = Color.Gray, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = (defaultAlarmMinutes ?: "").toString(), + onValueChange = defaultAlarmMinutesChanged, + label = { Text(stringResource(R.string.default_alarm_dialog_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + enabled = !isCreating + ) + + Spacer(modifier = Modifier.padding(12.dp)) + + // Default Alarm (All Day Events) + Text( + text = stringResource(R.string.add_calendar_alarms_default_all_day_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.default_alarm_dialog_message), + color = Color.Gray, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = (defaultAllDayAlarmMinutes ?: "").toString(), + onValueChange = defaultAllDayAlarmMinutesChanged, + label = { Text(stringResource(R.string.default_alarm_dialog_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + enabled = !isCreating + ) + + Spacer(modifier = Modifier.padding(12.dp)) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml deleted file mode 100644 index b9b8eca8..00000000 --- a/app/src/main/res/drawable/ic_add.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_forward.xml b/app/src/main/res/drawable/ic_arrow_forward.xml deleted file mode 100644 index 5304b93e..00000000 --- a/app/src/main/res/drawable/ic_arrow_forward.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml deleted file mode 100644 index 6541ee3e..00000000 --- a/app/src/main/res/drawable/ic_check.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml deleted file mode 100644 index d11cc5c9..00000000 --- a/app/src/main/res/drawable/ic_close.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml deleted file mode 100644 index f9213d2b..00000000 --- a/app/src/main/res/drawable/ic_delete.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml deleted file mode 100644 index 90406663..00000000 --- a/app/src/main/res/drawable/ic_share.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml deleted file mode 100644 index b3a9e036..00000000 --- a/app/src/main/res/drawable/ic_warning.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/layout/add_calendar_details.xml b/app/src/main/res/layout/add_calendar_details.xml deleted file mode 100644 index 9bb64c7a..00000000 --- a/app/src/main/res/layout/add_calendar_details.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/add_calendar_enter_url.xml b/app/src/main/res/layout/add_calendar_enter_url.xml deleted file mode 100644 index 61f0e726..00000000 --- a/app/src/main/res/layout/add_calendar_enter_url.xml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - -