diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..416b236 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +charset = utf-8 +indent_style = tab +indent_size = 4 +# ktlint configuration +ktlint_code_style = android +#ktlint_disabled_rules = +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/actions/gradle-cache/action.yml b/.github/actions/gradle-cache/action.yml new file mode 100644 index 0000000..0006295 --- /dev/null +++ b/.github/actions/gradle-cache/action.yml @@ -0,0 +1,11 @@ +name: Gradle Cache +description: 'Setup Gradle cache' + +runs: + using: 'composite' + steps: + - uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('gradle/libs.versions.toml') }} + restore-keys: ${{ runner.os }}-gradle diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml new file mode 100644 index 0000000..7108587 --- /dev/null +++ b/.github/workflows/deploy-release.yml @@ -0,0 +1,21 @@ +name: Deploy Release + +on: + release: + types: [ published ] + +jobs: + deploy: + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/gradle-cache + - name: Deploy release + env: + RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} + ORG_GRADLE_PROJECT_sonatypeUsername: ${{ secrets.SONATYPE_USERNAME }} + ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.SONATYPE_PASSWORD }} + ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SIGNING_PASSWORD }} + run: ./gradlew publishToSonatype closeSonatypeStagingRepository diff --git a/.github/workflows/verify-pr.yml b/.github/workflows/verify-pr.yml new file mode 100644 index 0000000..467ee25 --- /dev/null +++ b/.github/workflows/verify-pr.yml @@ -0,0 +1,22 @@ +name: Verify Pull Request + +on: [ pull_request, workflow_dispatch ] + +jobs: + check: + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/gradle-cache + - name: Check app + run: ./gradlew check + + test: + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/gradle-cache + - name: Run tests + run: ./gradlew test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..61b7abd --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Reveal \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..fb7f4a8 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..229a2c8 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/git_toolbox_prj.xml b/.idea/git_toolbox_prj.xml new file mode 100644 index 0000000..02b915b --- /dev/null +++ b/.idea/git_toolbox_prj.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..1620d83 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..44ca2d9 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,41 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..f750768 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8f22851 --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +The MIT license (MIT) + +Copyright (c) 2022 Sven Jacobs + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d8b02d --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +Reveal is a lightweight, simple reveal effect (also known as coach mark, onboarding, tutorial, etc.) +with a beautiful API for [Jetpack Compose](https://developer.android.com/jetpack/compose). + +![Demonstration](./assets/demo.gif) + +## Terminology + +| Term | Description | +|-------------|--------------------------------------------------------------------------------------------| +| Revealable | An element which is revealed on the screen. | +| Reveal area | The area which is revealed around the revealable. Usually with a slight padding. | +| Overlay | The overlay which greys out all contents except revealable. Can contain explanatory items. | + +## Getting started + +### Installation + +Add Reveal as a dependency to your project. It's available on Maven Central. + +```kotlin +dependencies { + implementation("com.svenjacobs.reveal:reveal-core:$REVEAL_VERSION") +} +``` + +### Compose + +Since you probably want the reveal effect to cover the whole screen, the `Reveal` composable should +be one of the top most composables in the hierarchy of your screen-level composable. + +```kotlin +@Composable +fun MainScreen( + modifier: Modifier = Modifier, +) { + val revealState = rememberRevealState() + + Reveal( + modifier = modifier.fillMaxSize(), + revealState = revealState, + onRevealableClick = {}, + onOverlayClick = {}, + ) { + // Contents + } +} +``` + +Inside `Reveal` specify revealable items via the `revealable` modifier. + +```kotlin +enum class Keys { HelloWorld } + +Column { + Text( + modifier = Modifier.revealable(key = Keys.HelloWorld), + text = "Hello world", + ) +} +``` + +Now launch the reveal effect via `revealState.reveal(Keys.HelloWorld)`. + +Nice, you just launched your first reveal effect. But what is missing is some explanatory item like +text or image next to the reveal area. So let's add one. + +Explanatory items are specified via `overlayContent` of the `Reveal` composable. + +```kotlin +Reveal( + overlayContent = { key -> + when (key) { + Keys.HelloWorld -> { + Surface( + modifier = Modifier + .align(RevealOverlayAlignment.Start) + .padding(8.dp), + shape = RoundedCornerShape(4.dp), + color = Color.White, + ) { + Text("This is an explanation") + } + } + } + } +) { + // Contents +} +``` + +The scope of the overlay content composable provides the `align()` modifier to align the item either +to the start, top, end or bottom of the reveal area. + +`Reveal` provides two click listeners: `onRevealableClick` is called when the reveal area is clicked +with the key of the current revealable as the first argument. `onOverlayClick` is called when the +overlay is clicked somewhere, also with the key argument. Use any of these click listeners to reveal +the next item, for example for some kind of tutorial, or to hide the effect via +`revealState.hide()`. + +That's it for now. For more details have a look at the [demo application](./demo-android) and the +JavaDoc. The library is well documented 😉 diff --git a/assets/demo.gif b/assets/demo.gif new file mode 100644 index 0000000..b7337d1 Binary files /dev/null and b/assets/demo.gif differ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..cb08b2c --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,39 @@ +import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.jetbrains.kotlin.android) apply false + alias(libs.plugins.nexus.publish) + alias(libs.plugins.ben.manes.versions) + alias(libs.plugins.kotlinter) +} + +group = Publication.group +version = Publication.version + +subprojects { + apply(plugin = "org.jmailen.kotlinter") + + kotlinter { + experimentalRules = true + } +} + +nexusPublishing { + repositories { + sonatype() + } +} + +tasks.withType { + + fun isNonStable(version: String) = + listOf("alpha", "beta", "rc", "eap", "-m", ".m", "-a", "dev").any { + version.toLowerCase().contains(it) + } + + rejectVersionIf { + isNonStable(candidate.version) && !isNonStable(currentVersion) + } +} diff --git a/buildSrc/.gitignore b/buildSrc/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/buildSrc/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..86890f1 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + `kotlin-dsl` +} + +repositories { + google() + mavenCentral() +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 0000000..e69de29 diff --git a/buildSrc/src/main/kotlin/Android.kt b/buildSrc/src/main/kotlin/Android.kt new file mode 100644 index 0000000..cd33733 --- /dev/null +++ b/buildSrc/src/main/kotlin/Android.kt @@ -0,0 +1,5 @@ +object Android { + const val minSdk = 21 + const val targetSdk = 33 + const val compileSdk = 33 +} diff --git a/buildSrc/src/main/kotlin/Publication.kt b/buildSrc/src/main/kotlin/Publication.kt new file mode 100644 index 0000000..9c4ae9e --- /dev/null +++ b/buildSrc/src/main/kotlin/Publication.kt @@ -0,0 +1,37 @@ +import org.gradle.api.publish.maven.MavenPublication + +object Publication { + const val group = "com.svenjacobs.reveal" + val version = (System.getenv("RELEASE_TAG_NAME") ?: "SNAPSHOT").let { it.replace("v", "") } +} + +fun MavenPublication.pomAttributes() { + pom { + name.set("Reveal") + description.set("Lightweight, simple reveal effect for Jetpack Compose") + url.set("https://github.com/svenjacobs/reveal") + + developers { + developer { + id.set("svenjacobs") + name.set("Sven Jacobs") + email.set("github@svenjacobs.com") + url.set("https://svenjacobs.com/") + timezone.set("GMT+1") + } + } + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + scm { + connection.set("scm:git:git://github.com/svenjacobs/reveal.git") + developerConnection.set("scm:git:git://github.com/svenjacobs/reveal.git") + url.set("https://github.com/svenjacobs/reveal") + } + } +} diff --git a/demo-android/.gitignore b/demo-android/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/demo-android/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/demo-android/build.gradle.kts b/demo-android/build.gradle.kts new file mode 100644 index 0000000..7a99b5d --- /dev/null +++ b/demo-android/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) +} + +android { + namespace = "com.svenjacobs.reveal.demo" + compileSdk = Android.compileSdk + + defaultConfig { + applicationId = "com.svenjacobs.reveal.demo" + minSdk = Android.minSdk + targetSdk = Android.targetSdk + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + } + + packagingOptions { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + + implementation(project(":reveal-core")) + implementation(composeBom) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) + + testImplementation(libs.junit) + androidTestImplementation(composeBom) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) +} diff --git a/demo-android/proguard-rules.pro b/demo-android/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/demo-android/src/androidTest/kotlin/com/svenjacobs/reveal/.gitcreate b/demo-android/src/androidTest/kotlin/com/svenjacobs/reveal/.gitcreate new file mode 100644 index 0000000..e69de29 diff --git a/demo-android/src/main/AndroidManifest.xml b/demo-android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bfdad24 --- /dev/null +++ b/demo-android/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + diff --git a/demo-android/src/main/kotlin/com/svenjacobs/reveal/demo/MainActivity.kt b/demo-android/src/main/kotlin/com/svenjacobs/reveal/demo/MainActivity.kt new file mode 100644 index 0000000..c016592 --- /dev/null +++ b/demo-android/src/main/kotlin/com/svenjacobs/reveal/demo/MainActivity.kt @@ -0,0 +1,20 @@ +package com.svenjacobs.reveal.demo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.core.view.WindowCompat +import com.svenjacobs.reveal.demo.ui.MainScreen + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + setContent { + MainScreen() + } + } +} diff --git a/demo-android/src/main/kotlin/com/svenjacobs/reveal/demo/ui/MainScreen.kt b/demo-android/src/main/kotlin/com/svenjacobs/reveal/demo/ui/MainScreen.kt new file mode 100644 index 0000000..b6a3f55 --- /dev/null +++ b/demo-android/src/main/kotlin/com/svenjacobs/reveal/demo/ui/MainScreen.kt @@ -0,0 +1,150 @@ +package com.svenjacobs.reveal.demo.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.svenjacobs.reveal.Key +import com.svenjacobs.reveal.Reveal +import com.svenjacobs.reveal.RevealOverlayAlignment +import com.svenjacobs.reveal.RevealOverlayScope +import com.svenjacobs.reveal.RevealShape +import com.svenjacobs.reveal.demo.ui.theme.DemoTheme +import com.svenjacobs.reveal.rememberRevealState +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private enum class Keys { Fab, Explanation } + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun MainScreen(modifier: Modifier = Modifier) { + val revealState = rememberRevealState() + + LaunchedEffect(Unit) { + delay(2.seconds) + revealState.reveal(Keys.Fab) + } + + DemoTheme { + val scope = rememberCoroutineScope() + + Reveal( + modifier = modifier, + revealState = revealState, + onRevealableClick = { key -> + scope.launch { + if (key == Keys.Fab) { + revealState.reveal(Keys.Explanation) + } else { + revealState.hide() + } + } + }, + onOverlayClick = { scope.launch { revealState.hide() } }, + overlayContent = { key -> RevealOverlayContent(key) }, + ) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + CenterAlignedTopAppBar( + title = { Text("Reveal Demo") }, + ) + }, + floatingActionButton = { + FloatingActionButton( + modifier = Modifier.revealable( + key = Keys.Fab, + shape = RevealShape.RoundRect(16.dp), + ), + onClick = { + scope.launch { revealState.reveal(Keys.Explanation) } + }, + ) { + Icon( + Icons.Filled.Add, + contentDescription = null, + ) + } + }, + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier + .padding(top = 16.dp) + .revealable( + key = Keys.Explanation, + ), + text = "Reveal is a lightweight, simple reveal effect (also known as " + + "coach mark or onboarding) library for Jetpack Compose.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Justify, + ) + } + } + } + } +} + +@Composable +fun RevealOverlayScope.RevealOverlayContent(key: Key) { + when (key) { + Keys.Fab -> OverlayText( + modifier = Modifier.align(RevealOverlayAlignment.Start), + text = "Click button to get started", + ) + Keys.Explanation -> OverlayText( + modifier = Modifier.align(RevealOverlayAlignment.Bottom), + text = "Actually we already started. This was an example of the reveal effect.", + ) + } +} + +@Composable +private fun OverlayText(text: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.padding(8.dp), + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + modifier = Modifier.padding(4.dp), + text = text, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +@Preview(showBackground = true) +private fun MainScreenPreview() { + DemoTheme { + MainScreen() + } +} diff --git a/demo-android/src/main/kotlin/com/svenjacobs/reveal/demo/ui/theme/Theme.kt b/demo-android/src/main/kotlin/com/svenjacobs/reveal/demo/ui/theme/Theme.kt new file mode 100644 index 0000000..5cff596 --- /dev/null +++ b/demo-android/src/main/kotlin/com/svenjacobs/reveal/demo/ui/theme/Theme.kt @@ -0,0 +1,11 @@ +package com.svenjacobs.reveal.demo.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable + +@Composable +fun DemoTheme(content: @Composable () -> Unit) { + MaterialTheme( + content = content, + ) +} diff --git a/demo-android/src/main/res/drawable-v24/ic_launcher_foreground.xml b/demo-android/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/demo-android/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/demo-android/src/main/res/drawable/ic_launcher_background.xml b/demo-android/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/demo-android/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/demo-android/src/main/res/mipmap-anydpi-v33/ic_launcher.xml b/demo-android/src/main/res/mipmap-anydpi-v33/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/demo-android/src/main/res/mipmap-anydpi-v33/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/demo-android/src/main/res/mipmap-hdpi/ic_launcher.webp b/demo-android/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/demo-android/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/demo-android/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/demo-android/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/demo-android/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/demo-android/src/main/res/mipmap-mdpi/ic_launcher.webp b/demo-android/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/demo-android/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/demo-android/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/demo-android/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/demo-android/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/demo-android/src/main/res/mipmap-xhdpi/ic_launcher.webp b/demo-android/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/demo-android/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/demo-android/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/demo-android/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/demo-android/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/demo-android/src/main/res/values/colors.xml b/demo-android/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/demo-android/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/demo-android/src/main/res/values/strings.xml b/demo-android/src/main/res/values/strings.xml new file mode 100644 index 0000000..39b94c6 --- /dev/null +++ b/demo-android/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Reveal Demo + diff --git a/demo-android/src/main/res/values/themes.xml b/demo-android/src/main/res/values/themes.xml new file mode 100644 index 0000000..fb81ee0 --- /dev/null +++ b/demo-android/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/demo-android/src/main/res/xml/backup_rules.xml b/demo-android/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/demo-android/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/demo-android/src/main/res/xml/data_extraction_rules.xml b/demo-android/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/demo-android/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/demo-android/src/test/kotlin/com/svenjacobs/reveal/.gitcreate b/demo-android/src/test/kotlin/com/svenjacobs/reveal/.gitcreate new file mode 100644 index 0000000..e69de29 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..0dee8cb --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..4465b24 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,38 @@ +[versions] +android-gradle-plugin = "7.4.0-beta05" +androidx-activity = "1.6.1" +androidx-compose-bom = "2022.11.00" # https://developer.android.com/jetpack/compose/setup#bom-version-mapping +androidx-compose-compiler = "1.3.2" +androidx-lifecycle = "2.6.0-alpha03" +kotlin = "1.7.20" + +[libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-material2 = { module = "androidx.compose.material:material" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-window-size = { module = "androidx.compose.material3:material3-window-size-class" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-core-ktx = "androidx.core:core-ktx:1.9.0" +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } +jakewharton-timber = "com.jakewharton.timber:timber:5.0.1" +twitter-compose-ktlint-rules = "com.twitter.compose.rules:ktlint:0.0.24" + +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-test-espresso-core = "androidx.test.espresso:espresso-core:3.5.0" +androidx-test-ext-junit = "androidx.test.ext:junit:1.1.4" +junit = "junit:junit:4.13.2" + +[plugins] +android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } +android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } +jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +ben-manes-versions = "com.github.ben-manes.versions:0.44.0" +nexus-publish = "io.github.gradle-nexus.publish-plugin:1.1.0" +kotlinter = "org.jmailen.kotlinter:3.12.0" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ae04661 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..a69d9cb --- /dev/null +++ b/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f127cfd --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/reveal-core/.gitignore b/reveal-core/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/reveal-core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/reveal-core/build.gradle.kts b/reveal-core/build.gradle.kts new file mode 100644 index 0000000..a2324a6 --- /dev/null +++ b/reveal-core/build.gradle.kts @@ -0,0 +1,101 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) + `maven-publish` + signing +} + +android { + namespace = "com.svenjacobs.reveal" + compileSdk = Android.compileSdk + + defaultConfig { + minSdk = Android.minSdk + targetSdk = Android.targetSdk + + aarMetadata { + minCompileSdk = Android.minSdk + } + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + } + + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + + implementation(composeBom) + api(libs.androidx.compose.foundation) + api(libs.androidx.compose.animation) + api(libs.androidx.compose.ui) + + debugApi(libs.androidx.compose.ui.tooling) + debugApi(libs.androidx.compose.ui.test.manifest) + + testImplementation(libs.junit) + androidTestImplementation(composeBom) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) +} + +publishing { + publications { + register("release") { + groupId = Publication.group + version = Publication.version + artifactId = "reveal-core" + + afterEvaluate { + from(components["release"]) + } + + pomAttributes() + } + } +} + +signing { + // Store key and password in environment variables + // ORG_GRADLE_PROJECT_signingKey and ORG_GRADLE_PROJECT_signingPassword + val signingKey: String? by project + val signingPassword: String? by project + useInMemoryPgpKeys(signingKey, signingPassword) + + sign(publishing.publications["release"]) +} diff --git a/reveal-core/consumer-rules.pro b/reveal-core/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/reveal-core/proguard-rules.pro b/reveal-core/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/reveal-core/src/androidTest/kotlin/com/svenjacobs/reveal/.gitcreate b/reveal-core/src/androidTest/kotlin/com/svenjacobs/reveal/.gitcreate new file mode 100644 index 0000000..e69de29 diff --git a/reveal-core/src/main/AndroidManifest.xml b/reveal-core/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8072ee0 --- /dev/null +++ b/reveal-core/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/reveal-core/src/main/kotlin/com/svenjacobs/reveal/Key.kt b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/Key.kt new file mode 100644 index 0000000..da01f76 --- /dev/null +++ b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/Key.kt @@ -0,0 +1,3 @@ +package com.svenjacobs.reveal + +typealias Key = Any diff --git a/reveal-core/src/main/kotlin/com/svenjacobs/reveal/Reveal.kt b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/Reveal.kt new file mode 100644 index 0000000..1370b2c --- /dev/null +++ b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/Reveal.kt @@ -0,0 +1,214 @@ +package com.svenjacobs.reveal + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.IntRect +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * Container composable for the reveal effect. + * + * When active, greys out its contents and only reveals current revealable element. + * Since the grey out effect should probably cover the whole screen, this composable should be one + * of the top most composables in the hierarchy, filling out all available space (`Modifier.fillMaxSize()`). + * + * Elements inside the contents of this composable are registered as "revealables" via the + * [RevealScope.revealable] modifier in the scope of the [content] composable. + * + * The effect is controlled via [RevealState.reveal] and [RevealState.hide]. + * + * Optionally an [overlayContent] can be specified to place explanatory elements (like texts or + * images) next to the reveal area. This content is placed above the greyed out backdrop. Elements + * in this scope can be aligned relative to the reveal area via [RevealOverlayScope.align]. + * + * @param onRevealableClick Called when the revealable area was clicked, where the + * parameter `key` is the key of the current revealable item. + * @param onOverlayClick Called when the overlay (greyed out area) is clicked somewhere + * outside of the current revealable, where the parameter `key` + * is the key of the current revealable. + * @param modifier Modifier applied to this composable. + * @param revealState State which controls the visibility of the reveal effect. + * @param overlayColor Animated background color of the overlay. + * See [overlayColorAnimationSpec]. + * @param overlayColorAnimationSpec Animation spec for the overlay background color. + * @param overlayContentAnimationSpec Animation spec for the animated alpha value of the overlay + * content. + * @param overlayContent Optional content which is placed above the greyed out backdrop + * and where its elements can be aligned relative to the reveal + * area via modifiers available in the scope of this composable. + * The `key` parameter is the key of the current visible + * revealable item. + * @param content Actual content which is visible when the Reveal composable is + * not active. Elements are registered as revealables via + * modifiers provided in the scope of this composable. + * + * @see RevealState + * @see RevealScope + * @see RevealOverlayScope + */ +@Composable +fun Reveal( + onRevealableClick: (key: Key) -> Unit, + onOverlayClick: (key: Key) -> Unit, + modifier: Modifier = Modifier, + revealState: RevealState = rememberRevealState(), + overlayColor: Color = Color.Black.copy(alpha = 0.8f), + overlayColorAnimationSpec: AnimationSpec = tween(durationMillis = 500), + overlayContentAnimationSpec: AnimationSpec = tween(durationMillis = 500), + overlayContent: @Composable RevealOverlayScope.(key: Key) -> Unit = {}, + content: @Composable RevealScope.() -> Unit, +) { + val animatedOverlayColor by animateColorAsState( + targetValue = if (revealState.visible) overlayColor else Color.Transparent, + animationSpec = overlayColorAnimationSpec, + ) + val animatedOverlayContentAlpha by animateFloatAsState( + targetValue = if (revealState.visible) 1.0f else 0.0f, + animationSpec = overlayContentAnimationSpec, + ) + var layoutCoordinates by remember { mutableStateOf(null) } + val positionInRoot by remember { + derivedStateOf { layoutCoordinates?.positionInRoot() ?: Offset.Zero } + } + val layoutDirection = LocalLayoutDirection.current + val density = LocalDensity.current + // Rect (in pixels) of the reveal area including padding + val currentRevealableRect by remember { + derivedStateOf { + revealState.currentRevealable?.let { revealable -> + val pos = revealable.layoutCoordinates.positionInRoot() - positionInRoot + with(density) { + val rect = Rect( + left = pos.x - + revealable.padding.calculateLeftPadding(layoutDirection).toPx(), + top = pos.y - + revealable.padding.calculateTopPadding().toPx(), + right = pos.x + + revealable.padding.calculateRightPadding(layoutDirection).toPx() + + revealable.layoutCoordinates.size.width.toFloat(), + bottom = pos.y + + revealable.padding.calculateBottomPadding().toPx() + + revealable.layoutCoordinates.size.height.toFloat(), + ) + + if (revealable.shape == RevealShape.Circle) { + Rect(rect.center, rect.maxDimension / 2.0f) + } else { + rect + } + } + } + } + } + + Box( + modifier = modifier.onGloballyPositioned { layoutCoordinates = it }, + ) { + content(RevealScopeInstance(revealState)) + + Pair( + revealState.currentRevealable, + currentRevealableRect, + ).safe { revealable, rect -> + val clickModifier = when (revealState.visible) { + true -> Modifier.pointerInput(Unit) { + detectTapGestures( + onPress = { offset -> + revealState.currentRevealable?.key?.let( + if (currentRevealableRect?.contains(offset) == true) { + onRevealableClick + } else { + onOverlayClick + }, + ) + }, + ) + } + false -> Modifier + } + + Box( + modifier = clickModifier + .matchParentSize() + .drawBehind { + val path = Path().apply { + when (val shape = revealable.shape) { + RevealShape.Rect -> addRect(rect) + RevealShape.Circle -> addOval(rect) + is RevealShape.RoundRect -> { + val size = with(density) { shape.cornerSize.toPx() } + addRoundRect(RoundRect(rect, CornerRadius(size, size))) + } + } + } + + clipPath(path, clipOp = ClipOp.Difference) { + drawRect(animatedOverlayColor) + } + }, + ) { + // Optimization: don't place element into composition if it isn't visible at all + if (animatedOverlayContentAlpha > 0f) { + val intRect = IntRect( + left = rect.left.toInt(), + top = rect.top.toInt(), + right = rect.right.toInt(), + bottom = rect.bottom.toInt(), + ) + + Box( + modifier = Modifier + .matchParentSize() + .alpha(animatedOverlayContentAlpha), + content = { + RevealOverlayScopeInstance(intRect).overlayContent( + revealable.key, + ) + }, + ) + } + } + } + } +} + +@OptIn(ExperimentalContracts::class) +private inline fun Pair.safe(body: (A, B) -> R): R? { + contract { + callsInPlace(body, InvocationKind.AT_MOST_ONCE) + } + return if (first == null || second == null) { + null + } else { + body(first!!, second!!) + } +} diff --git a/reveal-core/src/main/kotlin/com/svenjacobs/reveal/RevealOverlay.kt b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/RevealOverlay.kt new file mode 100644 index 0000000..f39875b --- /dev/null +++ b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/RevealOverlay.kt @@ -0,0 +1,82 @@ +package com.svenjacobs.reveal + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection + +enum class RevealOverlayAlignment { + Start, Top, End, Bottom, +} + +/** + * Scope for overlay content which provides a Modifier to align an element relative to the + * reveal area. + * + * @see align + */ +@Immutable +interface RevealOverlayScope { + + /** + * Aligns the element either to the start, top, end or bottom of the reveal area. + * + * Should be one of the first modifiers applied to the element so that other modifiers are + * applied after the element was positioned. + * + * @see RevealOverlayAlignment + */ + @Stable + fun Modifier.align(alignment: RevealOverlayAlignment): Modifier +} + +internal class RevealOverlayScopeInstance( + private val revealRect: IntRect, +) : RevealOverlayScope { + + // TODO: fix RTL layout + override fun Modifier.align(alignment: RevealOverlayAlignment): Modifier = this.then( + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(constraints.maxWidth, constraints.maxHeight) { + val horizontalCenterX = + revealRect.left + (revealRect.width - placeable.width) / 2 + val verticalCenterY = + revealRect.top + (revealRect.height - placeable.height) / 2 + + val actualAlignment = when { + layoutDirection == LayoutDirection.Rtl && + alignment == RevealOverlayAlignment.Start -> RevealOverlayAlignment.End + layoutDirection == LayoutDirection.Rtl && + alignment == RevealOverlayAlignment.End -> RevealOverlayAlignment.Start + else -> alignment + } + + when (actualAlignment) { + RevealOverlayAlignment.Start -> + placeable.place( + x = revealRect.left - placeable.width, + y = verticalCenterY, + ) + RevealOverlayAlignment.Top -> + placeable.place( + x = horizontalCenterX, + y = revealRect.top - placeable.height, + ) + RevealOverlayAlignment.End -> + placeable.place( + x = revealRect.right, + y = verticalCenterY, + ) + RevealOverlayAlignment.Bottom -> + placeable.place( + x = horizontalCenterX, + y = revealRect.bottom, + ) + } + } + }, + ) +} diff --git a/reveal-core/src/main/kotlin/com/svenjacobs/reveal/RevealScope.kt b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/RevealScope.kt new file mode 100644 index 0000000..2127660 --- /dev/null +++ b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/RevealScope.kt @@ -0,0 +1,56 @@ +package com.svenjacobs.reveal + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.dp + +/** + * Scope inside [Reveal]'s contents which provides [revealable] modifier. + */ +@Immutable +interface RevealScope { + + /** + * Registers the element as a revealable item. + * + * [key] must be unique in the current scope and should be used for [RevealState.reveal]. + * Internally [Modifier.onGloballyPositioned] is used. Hence elements are only registered after + * they have been laid out. + * + * @param key Unique key to identify the revealable content. + * @param padding Additional padding around the reveal area. Positive values increase area while + * negative values decrease it. Defaults to 8 dp on all sides. + * @param shape Shape of the reveal effect around the element. Defaults to a rounded rect + * with a corner size of 4 dp. + */ + fun Modifier.revealable( + key: Key, + padding: PaddingValues = PaddingValues(8.dp), + shape: RevealShape = RevealShape.RoundRect(4.dp), + ): Modifier +} + +internal class RevealScopeInstance( + private val revealState: RevealState, +) : RevealScope { + + override fun Modifier.revealable( + key: Key, + padding: PaddingValues, + shape: RevealShape, + ): Modifier = + this.then( + Modifier.onGloballyPositioned { layoutCoordinates -> + revealState.putRevealable( + Revealable( + key = key, + layoutCoordinates = layoutCoordinates, + padding = padding, + shape = shape, + ), + ) + }, + ) +} diff --git a/reveal-core/src/main/kotlin/com/svenjacobs/reveal/RevealShape.kt b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/RevealShape.kt new file mode 100644 index 0000000..ffa6ab6 --- /dev/null +++ b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/RevealShape.kt @@ -0,0 +1,17 @@ +package com.svenjacobs.reveal + +import androidx.compose.ui.unit.Dp + +/** + * Shape of the reveal area. + */ +sealed interface RevealShape { + + object Rect : RevealShape + + object Circle : RevealShape + + data class RoundRect( + val cornerSize: Dp, + ) : RevealShape +} diff --git a/reveal-core/src/main/kotlin/com/svenjacobs/reveal/RevealState.kt b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/RevealState.kt new file mode 100644 index 0000000..9ceb617 --- /dev/null +++ b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/RevealState.kt @@ -0,0 +1,43 @@ +package com.svenjacobs.reveal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +@Stable +class RevealState { + + private val mutex = Mutex() + + internal var visible by mutableStateOf(false) + private set + internal var currentRevealable by mutableStateOf(null) + private set + private val revealables: MutableMap = mutableMapOf() + + suspend fun reveal(key: Key) { + mutex.withLock { + // TODO: hide when key was not found? + currentRevealable = revealables[key] + visible = true + } + } + + suspend fun hide() { + mutex.withLock { + visible = false + } + } + + internal fun putRevealable(revealable: Revealable) { + revealables[revealable.key] = revealable + } +} + +@Composable +fun rememberRevealState() = remember { RevealState() } diff --git a/reveal-core/src/main/kotlin/com/svenjacobs/reveal/Revealable.kt b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/Revealable.kt new file mode 100644 index 0000000..de04ed5 --- /dev/null +++ b/reveal-core/src/main/kotlin/com/svenjacobs/reveal/Revealable.kt @@ -0,0 +1,25 @@ +package com.svenjacobs.reveal + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Immutable +import androidx.compose.ui.layout.LayoutCoordinates + +@Immutable +internal class Revealable( + val key: Key, + val layoutCoordinates: LayoutCoordinates, + val padding: PaddingValues, + val shape: RevealShape, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Revealable) return false + + if (key != other.key) return false + + return true + } + + override fun hashCode(): Int = key.hashCode() +} diff --git a/reveal-core/src/test/kotlin/com/svenjacobs/reveal/.gitcreate b/reveal-core/src/test/kotlin/com/svenjacobs/reveal/.gitcreate new file mode 100644 index 0000000..e69de29 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..601b893 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Reveal" +include( + ":reveal-core", + ":demo-android", +)