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",
+)