diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..56a49dc --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,41 @@ +name: Deploy Compose Web App + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: write + +jobs: + test-and-build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }} + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Build + run: | + ./gradlew :composeApp:wasmJsBrowserDistribution --no-configuration-cache + + - name: Deploy + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: composeApp/build/dist/wasmJs/productionExecutable \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 876b18e..7aa3c4e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -54,4 +54,11 @@ jobs: SIGNING_KEY: ${{ secrets.SIGNING_KEY }} - SONATYPE_STAGING_PROFILE_ID: ${{ secrets.SONATYPE_STAGING_PROFILE_ID }} \ No newline at end of file + SONATYPE_STAGING_PROFILE_ID: ${{ secrets.SONATYPE_STAGING_PROFILE_ID }} + + + - name: HTTP Request Action + uses: fjogeleit/http-request-action@v1.16.4 + with: + url: 'https://ossrh-staging-api.central.sonatype.com/manual/upload/defaultRepository/ly.com.tahaben' + bearerToken: ${{ secrets.OSSRH_HEADER }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index bbfdafb..0b94248 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,15 @@ local.properties /.idea/migrations.xml /.idea/misc.xml /.idea/vcs.xml -/convention-plugins/build/* \ No newline at end of file +/convention-plugins/build/* +**/build/ +xcuserdata +!src/**/build/ +.idea +captures +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings \ No newline at end of file diff --git a/README.md b/README.md index f2fac92..789b5a0 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,25 @@ # Showcase Layout Compose -Create a beautiful animated showcase effect for your compose UIs easily ! +Create beautiful animated showcase effects for your compose UIs easily! -**Now with multiplatform support :D** +**Now with multiplatform support and two different showcase layouts to choose from:** +- **ShowcaseLayout**: Classic full-screen overlay with cutouts +- **TargetShowcaseLayout**: Modern targeted highlighting with customizable shapes + +## Web demo +[Click here](https://tahaak67.github.io/ShowcaseLayoutCompose/index.html) to try showcase layout for web in your browser! ## Demo -Library demo GIF +| ShowcaseLayout | +|:---------------------------------------------------------------------------:| +| Library demo GIF | + +https://github.com/user-attachments/assets/faa5dc19-606a-4731-80b1-44cbf6d08fdc + + + Library demo GIF.Library demo GIF @@ -31,7 +43,7 @@ Showcase Layout Compose can be used in **both** Jetpack Compose (native Android) Add the dependency to your module's `build.gradle` file like below ``` kotlin -implementation("ly.com.tahaben:showcase-layout-compose:1.0.5") +implementation("ly.com.tahaben:showcase-layout-compose:1.0.8") ``` ## Usage @@ -73,7 +85,10 @@ Text( modifier = Modifier.showcase( // should start with 1 and increment with 1 for each time you use Modifier.showcase() index = 1, - message = + message = ShowcaseMsg( + "This is a showcase message", + textStyle = TextStyle(color = Color.White) + ) ), text = "ShowcaseLayout Test 1" ) @@ -122,7 +137,96 @@ similarly you can show a greeting using showGreeting and passing th -Done, our text is now showcased!, customize it further with Additional parameters +Done, our text is now showcased!, customize it further with Additional parameters. + +## TargetShowcaseLayout (New!) + +Starting from version 1.0.6, Showcase Layout Compose now offers a new layout option: `TargetShowcaseLayout`. This layout provides a different visual approach to showcasing UI elements by highlighting specific targets with customizable shapes rather than the full-screen approach of the original ShowcaseLayout. + +### Key Features + +- Highlights target elements with customizable shapes (circle, rectangle, or rounded rectangle) +- Smooth animations between targets +- Pulsing effect around the target for better visibility +- All the same customization options as the original ShowcaseLayout + +### Usage + +You can use TargetShowcaseLayout directly: + +```kotlin +var isShowcasing by remember { mutableStateOf(true) } + +TargetShowcaseLayout( + isShowcasing = isShowcasing, + onFinish = { isShowcasing = false }, + targetShape = TargetShape.ROUNDED_RECTANGLE, // CIRCLE, RECTANGLE, or ROUNDED_RECTANGLE + cornerRadius = 8.dp, // Only used with ROUNDED_RECTANGLE + animateToNextTarget = true, // Smooth animation between targets + greeting = ShowcaseMsg( + "Welcome to TargetShowcaseLayout!", + textStyle = TextStyle(color = Color.White) + ) +) { + // Your UI content here + Column { + Text( + modifier = Modifier.showcase( + index = 1, + message = ShowcaseMsg( + "This element is highlighted with TargetShowcaseLayout", + textStyle = TextStyle(color = Color.White) + ) + ), + text = "Target Showcase Example" + ) + } +} +``` + +| TargetShowcaseLayout with CIRCLE shape | TargetShowcaseLayout with RECTANGLE shape | TargetShowcaseLayout with ROUNDED_RECTANGLE shape | +|:------------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------:|:------------------------------------------------------------------------------------------------------:| +| ![screenshot-targetshocase-circle.png](metadata/screenshots/screenshot-targetshocase-circle.png) | ![screenshot-targetshocase-rect.png](metadata/screenshots/screenshot-targetshocase-rect.png) | ![screenshot-targetshocase-roundrect.png](metadata/screenshots/screenshot-targetshocase-roundrect.png) | + +### TargetShowcaseLayout vs ShowcaseLayout + +| Feature | TargetShowcaseLayout | ShowcaseLayout | +|---------|---------------------|----------------| +| Visual style | Highlights specific targets with shapes | Full-screen overlay with cutouts | +| Target shapes | Circle, Rectangle, Rounded Rectangle | Circle, Rectangle, Rounded Rectangle | +| Animations | Smooth transitions between targets | Fade transitions | +| Pulsing effect | Yes | No | +| Use cases | Focused UI tours, precise element highlighting | General app tours, feature introductions | + +### TargetShowcaseLayout Parameters + +In addition to the parameters shared with ShowcaseLayout, TargetShowcaseLayout offers: + +```kotlin +TargetShowcaseLayout( + // Common parameters (same as ShowcaseLayout) + isShowcasing = isShowcasing, + isDarkLayout = false, + initIndex = 0, + animationDuration = 1000, + onFinish = { isShowcasing = false }, + greeting = ShowcaseMsg( + "Welcome to TargetShowcaseLayout!", + textStyle = TextStyle(color = Color.White) + ), + lineThickness = 5.dp, + + // TargetShowcaseLayout specific parameters + targetShape = TargetShape.CIRCLE, // CIRCLE, RECTANGLE, or ROUNDED_RECTANGLE + cornerRadius = 8.dp, // Only used with ROUNDED_RECTANGLE + animateToNextTarget = true // Smooth animation between targets, otherwise shrink and expand on each target +) { + // Your UI content here +} +``` + + + #### Additional parameters @@ -294,11 +398,17 @@ In recent releases logs have been disabled by default, to print log statement of ``` -## Complete Example +## Complete Examples + +### ShowcaseLayout Example +For a complete example of the original ShowcaseLayout, check out [MainScreen.kt](https://github.com/tahaak67/ShowcaseLayoutCompose/blob/main/app/src/main/java/ly/com/tahaben/showcaselayoutcompose/ui/MainScreen.kt). + +### TargetShowcaseLayout Example +For an example of the new TargetShowcaseLayout, check out the [App.kt](https://github.com/tahaak67/ShowcaseLayoutCompose/blob/main/composeApp/src/commonMain/kotlin/App.kt) file in the composeApp module. + +You can also clone/download this repository and run the demo app to see both layouts in action. + -For a complete example check -out [MainScreen.kt](https://github.com/tahaak67/ShowcaseLayoutCompose/blob/main/app/src/main/java/ly/com/tahaben/showcaselayoutcompose/ui/MainScreen.kt) \ -or clone/download this repository and check the app module. ## Contributing diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 9344f8f..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,68 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' -} - -android { - namespace "ly.com.tahaben.showcaselayoutcompose" - compileSdk 34 - - defaultConfig { - applicationId "ly.com.tahaben.showcaselayoutcompose" - minSdk 21 - targetSdk 33 - versionCode 5 - versionName "1.0.4" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables { - useSupportLibrary true - } - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' - } - buildFeatures { - compose true - buildConfig true - } - composeOptions { - kotlinCompilerExtensionVersion '1.5.11' - } - packagingOptions { - resources { - excludes += '/META-INF/{AL2.0,LGPL2.1}' - } - } -} - -dependencies { - def composeBom = platform('androidx.compose:compose-bom:2024.02.02') - implementation composeBom - androidTestImplementation composeBom - implementation 'androidx.core:core-ktx:1.12.0' - implementation "androidx.compose.ui:ui" - implementation "androidx.compose.material:material" - implementation("androidx.navigation:navigation-compose:2.7.6") - implementation "androidx.compose.ui:ui-tooling-preview" - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' - implementation 'androidx.activity:activity-compose:1.8.2' - implementation project(':showcase-layout-compose') - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4" - debugImplementation "androidx.compose.ui:ui-tooling" - debugImplementation "androidx.compose.ui:ui-test-manifest" -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..98049b2 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,67 @@ +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.jetbrainsKotlinAndroid) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "ly.com.tahaben.showcaselayoutcompose" + compileSdk = 34 + + defaultConfig { + applicationId = "ly.com.tahaben.showcaselayoutcompose" + minSdk = 21 + targetSdk = 33 + versionCode= 5 + versionName ="1.0.4" + + testInstrumentationRunner= "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary= true + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility= JavaVersion.VERSION_1_8 + targetCompatibility =JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose =true + buildConfig= true + } + + packagingOptions { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.02.02") + implementation(composeBom) + androidTestImplementation(composeBom) + implementation ("androidx.core:core-ktx:1.12.0") + implementation ("androidx.compose.ui:ui") + implementation ("androidx.compose.material:material") + implementation("androidx.navigation:navigation-compose:2.7.6") + implementation ("androidx.compose.ui:ui-tooling-preview") + implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation ("androidx.activity:activity-compose:1.8.2") + implementation (project(":showcase-layout-compose")) + testImplementation ("junit:junit:4.13.2") + androidTestImplementation ("androidx.test.ext:junit:1.1.5") + androidTestImplementation ("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation ("androidx.compose.ui:ui-test-junit4") + debugImplementation ("androidx.compose.ui:ui-tooling") + debugImplementation ("androidx.compose.ui:ui-test-manifest") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..ff59496 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,6 +1,6 @@ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. +# proguardFiles setting in build.gradle.kts. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 0cc73ff..0000000 --- a/build.gradle +++ /dev/null @@ -1,16 +0,0 @@ -buildscript { - repositories { - mavenCentral() - google() - } -}// Top-level build file where you can add configuration options common to all sub-projects/modules. -plugins { - id 'com.android.library' version '8.2.2' apply false - id 'org.jetbrains.kotlin.android' version '1.9.23' apply false - id 'org.jetbrains.kotlin.multiplatform' version '1.9.23' apply false - id 'org.jetbrains.compose' version '1.6.1' apply false - id("io.github.gradle-nexus.publish-plugin") version "1.3.0" -} - - -apply from: "${rootDir}/scripts/publish-root.gradle" \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..4db520a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,19 @@ +buildscript { + repositories { + mavenCentral() + google() + } +} +plugins { + // this is necessary to avoid the plugins to be loaded multiple times + // in each subproject's classloader + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.jetbrainsCompose) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.jetbrainsKotlinAndroid) apply false + alias(libs.plugins.nexusPublishing) +} + +apply("${rootDir}/scripts/publish-root.gradle") diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts new file mode 100644 index 0000000..ef4c291 --- /dev/null +++ b/composeApp/build.gradle.kts @@ -0,0 +1,122 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidApplication) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) +} + +kotlin { + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = "11" + } + } + } + + jvm("desktop") + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = "composeApp" + browser { + commonWebpackConfig { + outputFileName = "composeApp.js" + devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { + static = (static ?: mutableListOf()).apply { + // Serve sources to debug inside browser + add(project.projectDir.path) + } + } + } + } + binaries.executable() + } + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "ComposeApp" + isStatic = true + } + } + + sourceSets { + val desktopMain by getting + + androidMain.dependencies { + implementation(libs.compose.ui.tooling.preview) + implementation(libs.androidx.activity.compose) + } + commonMain.dependencies { + implementation(project(":showcase-layout-compose")) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + } + desktopMain.dependencies { + implementation(compose.desktop.currentOs) + } + } +} + +android { + namespace = "ly.com.tahaben.showcaselayoutcomposekmp" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + sourceSets["main"].res.srcDirs("src/androidMain/res") + sourceSets["main"].resources.srcDirs("src/commonMain/resources") + + defaultConfig { + applicationId = "ly.com.tahaben.showcaselayoutcomposekmp" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + dependencies { + debugImplementation(libs.compose.ui.tooling) + } +} + +compose.desktop { + application { + mainClass = "MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "ly.com.tahaben.showcaselayoutcomposekmp" + packageVersion = "1.0.0" + } + } +} + +compose.experimental { + web.application {} +} diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..b87d944 --- /dev/null +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/kotlin/OpenUrl.kt b/composeApp/src/androidMain/kotlin/OpenUrl.kt new file mode 100644 index 0000000..f755b6f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/OpenUrl.kt @@ -0,0 +1,20 @@ +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.content.ContextCompat + + +class UrlLauncherAndroid(private val context: Context): UrlLauncher{ + + override fun openUrl(url: String): Boolean{ + return try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ContextCompat.startActivity(context, intent, null) + true + }catch (e: Exception){ + + false + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/Platform.android.kt b/composeApp/src/androidMain/kotlin/Platform.android.kt new file mode 100644 index 0000000..4f3ea05 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/Platform.android.kt @@ -0,0 +1,7 @@ +import android.os.Build + +class AndroidPlatform : Platform { + override val name: String = "Android ${Build.VERSION.SDK_INT}" +} + +actual fun getPlatform(): Platform = AndroidPlatform() \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/ly/com/tahaben/showcaselayoutcomposekmp/MainActivity.kt b/composeApp/src/androidMain/kotlin/ly/com/tahaben/showcaselayoutcomposekmp/MainActivity.kt new file mode 100644 index 0000000..26f77c6 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/ly/com/tahaben/showcaselayoutcomposekmp/MainActivity.kt @@ -0,0 +1,17 @@ +package ly.com.tahaben.showcaselayoutcomposekmp + +import App +import UrlLauncherAndroid +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val urlLauncherAndroid = UrlLauncherAndroid(applicationContext) + setContent { + App(openUrl = urlLauncherAndroid::openUrl) + } + } +} diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..a571e60 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..61da551 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c41dd28 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..db5080a Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..6dba46d Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..da31a87 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..15ac681 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b216f2d Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..f25a419 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e96783c Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000..b8c0401 --- /dev/null +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -0,0 +1,3 @@ + + slc test + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/circle.png b/composeApp/src/commonMain/composeResources/drawable/circle.png new file mode 100644 index 0000000..6ff8d79 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/circle.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml new file mode 100644 index 0000000..d7bf795 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/rounded_square.png b/composeApp/src/commonMain/composeResources/drawable/rounded_square.png new file mode 100644 index 0000000..790baeb Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/rounded_square.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/square.png b/composeApp/src/commonMain/composeResources/drawable/square.png new file mode 100644 index 0000000..5c3917b Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/square.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/triangle.png b/composeApp/src/commonMain/composeResources/drawable/triangle.png new file mode 100644 index 0000000..c21bd88 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/triangle.png differ diff --git a/composeApp/src/commonMain/composeResources/font/noto_color_emoji_regular.ttf b/composeApp/src/commonMain/composeResources/font/noto_color_emoji_regular.ttf new file mode 100644 index 0000000..041d9d9 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/font/noto_color_emoji_regular.ttf differ diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt new file mode 100644 index 0000000..624a7a3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -0,0 +1,744 @@ +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import ly.com.tahaben.showcase_layout_compose.domain.Level +import ly.com.tahaben.showcase_layout_compose.domain.ShowcaseEventListener +import ly.com.tahaben.showcase_layout_compose.model.* +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.ui.tooling.preview.Preview +import showcase_layout_compose_kmp.composeapp.generated.resources.* +import kotlin.math.roundToInt + +@OptIn(ExperimentalResourceApi::class, ExperimentalMaterial3Api::class) +@Preview +@Composable +fun App(openUrl: (String) -> Boolean, onWebLoadFinish: () -> Unit = {}) { + LaunchedEffect(key1 = Unit, block = { onWebLoadFinish() }) + + var selectedTarget by remember { mutableStateOf("Toolbar title") } + var selectTargetMenuExpaned by remember { mutableStateOf(false) } + var selectHeadMenuExpaned by remember { mutableStateOf(false) } + var selectTargetShapeMenuExpaned by remember { mutableStateOf(false) } + val clipboardManager = LocalClipboardManager.current + val snackbarHostState = remember { SnackbarHostState() } + var finishedSubsequentShowcase by remember { mutableStateOf(false) } + // State to track which layout is currently selected (ShowcaseLayout or TargetShowcaseLayout) + var useTargetShowcaseLayout by remember { mutableStateOf(false) } + val targets = + mapOf( + "Toolbar title" to 1, + "Toolbar menu icon" to 2, + "Showcase Button" to 3, + "Target Dropdown menu" to 4 + ) + val arrowHeadMap = + mapOf( + "Triangle" to Res.drawable.triangle, + "Circle" to Res.drawable.circle, + "Square" to Res.drawable.square, + "Rounded Square" to Res.drawable.rounded_square + ) + val targetShapeMap = + mapOf( + "Triangle" to Res.drawable.triangle, + "Circle" to Res.drawable.circle, + "Rectangle" to Res.drawable.square, + "Rounded Rectangle" to Res.drawable.rounded_square + ) + var selectedHead by remember { mutableStateOf("Triangle") } + val headType by derivedStateOf { + when (selectedHead) { + "Triangle" -> Head.TRIANGLE + "Circle" -> Head.CIRCLE + "Square" -> Head.SQUARE + "Rounded Square" -> Head.ROUND_SQUARE + else -> null + } + } + val coroutineScope = rememberCoroutineScope() + var isShowcasing by remember { mutableStateOf(false) } + var animateHead by remember { mutableStateOf(false) } + var animateMsg by remember { mutableStateOf(false) } + val msgBackground by remember { mutableStateOf(Color.Yellow) } + var msgCornerRadius by remember { mutableStateOf(0) } + var headSize by remember { mutableStateOf(25f) } + var lineThinckness by remember { mutableStateOf(5) } + var durationSliderValue by remember { mutableStateOf(500) } + var animationDuration by remember { mutableStateOf(500) } + var animateToNextTarget by remember { mutableStateOf(true) } + var targetShape by remember { mutableStateOf(TargetShape.CIRCLE) } + val targetShapeName by derivedStateOf { + when(targetShape){ + TargetShape.CIRCLE -> "Circle" + TargetShape.RECTANGLE -> "Rectangle" + TargetShape.ROUNDED_RECTANGLE -> "Rounded Rectangle" + } + } + val scrollState = rememberScrollState() + val greetingMsg = buildAnnotatedString { + withStyle(ParagraphStyle(textAlign = TextAlign.Center)){ + append("Welcome to ") + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append("Showcase Layout Compose Demo ") + pop() + append("lets take you on a quick tour!") + } + } + + MyTheme(useDarkTheme = false) { + ShowcaseLayoutWrapper( + useTargetShowcaseLayout = useTargetShowcaseLayout, + isShowcasing = isShowcasing, + onFinish = { isShowcasing = false; finishedSubsequentShowcase = true }, + greeting = ShowcaseMsg(greetingMsg, textStyle = TextStyle(color = Color.White)), + lineThickness = lineThinckness.dp, + animationDuration = animationDuration, + targetShape = targetShape, + animateToNextTarget = animateToNextTarget + ) { + Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { + TopAppBar(title = { + Text( + modifier = Modifier + .showcase( + 1, message = ShowcaseMsg( + text = "This is the title of the toolbar 🤫", + msgBackground = Color(0xffb7ffb3), + roundedCorner = msgCornerRadius.dp, + arrow = Arrow( + animSize = animateHead, + head = headType, + headSize = headSize, + animationDuration = animationDuration + ), + enterAnim = if (animateMsg) MsgAnimation.FadeInOut(animationDuration) else MsgAnimation.None + ) + ), + text = "Showcase Layout Compose Demo" + ) + }, actions = { + IconButton( + onClick = {}, modifier = Modifier.showcase( + 2, message = ShowcaseMsg( + text = "This is the menu button click here to see the menu.", + msgBackground = Color(0xff0042fd), + roundedCorner = msgCornerRadius.dp, + arrow = Arrow( + animSize = animateHead, + head = headType, + headSize = headSize, + curved = true, + animationDuration = animationDuration + ), + textStyle = TextStyle(color = Color.White, fontWeight = FontWeight.Bold), + enterAnim = if (animateMsg) MsgAnimation.FadeInOut(animationDuration) else MsgAnimation.None + ) + ) + ) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + }) + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.showcase( + 5, message = ShowcaseMsg( + text = "Switch between full screen showcase and target showcase from here, Try it now ;)", + msgBackground = Color(0xff000000), + textStyle = TextStyle(color = Color.White, fontWeight = FontWeight.Bold), + ) + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Layout Type", modifier = Modifier.width(100.dp)) + Switch( + checked = useTargetShowcaseLayout, + onCheckedChange = { useTargetShowcaseLayout = it } + ) + Text(if (useTargetShowcaseLayout) "Target Showcase Layout" else "Showcase Layout") + } + + Row( + modifier = Modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Target", modifier = Modifier.width(100.dp)) + ExposedDropdownMenuBox( + expanded = selectTargetMenuExpaned, + onExpandedChange = { selectTargetMenuExpaned = it }) { + OutlinedTextField( + modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable).showcase( + 4, message = ShowcaseMsg( + text = buildAnnotatedString { + append("You can choose what target to showcase form here then click the ") + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append("Showcase!") + pop() + append(" button") + }, + msgBackground = Color(0xff450099), + roundedCorner = msgCornerRadius.dp, + arrow = Arrow( + animSize = animateHead, + head = headType, + headSize = headSize, + animationDuration = animationDuration + ), + textStyle = TextStyle(color = Color.White, fontWeight = FontWeight.Bold), + enterAnim = if (animateMsg) MsgAnimation.FadeInOut(animationDuration) else MsgAnimation.None + ) + ), + value = selectedTarget, + onValueChange = {}, + readOnly = true, + singleLine = true, + label = {}, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = selectTargetMenuExpaned) }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + ) + ExposedDropdownMenu( + expanded = selectTargetMenuExpaned, + onDismissRequest = { selectTargetMenuExpaned = false } + ) { + targets.forEach { (name, index) -> + DropdownMenuItem( + text = { + Text( + text = name, + style = MaterialTheme.typography.bodyLarge + ) + }, + onClick = { + selectedTarget = name + selectTargetMenuExpaned = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding + ) + } + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Head style", modifier = Modifier.width(100.dp)) + ExposedDropdownMenuBox( + expanded = selectHeadMenuExpaned, + onExpandedChange = { selectHeadMenuExpaned = it }) { + OutlinedTextField( + modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable), + value = selectedHead, + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { }, + trailingIcon = { + Image( + modifier = Modifier.size(18.dp), + painter = painterResource(arrowHeadMap[selectedHead]!!), + contentDescription = null + ) + }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + ) + ExposedDropdownMenu( + expanded = selectHeadMenuExpaned, + onDismissRequest = { selectHeadMenuExpaned = false } + ) { + DropdownMenuItem( + text = { + Text( + text = "Triangle", + style = MaterialTheme.typography.bodyLarge + ) + }, + onClick = { + selectedHead = "Triangle" + selectHeadMenuExpaned = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + trailingIcon = { + Image( + modifier = Modifier.size(18.dp), + painter = painterResource(Res.drawable.triangle), + contentDescription = null + ) + } + ) + DropdownMenuItem( + text = { + Text( + text = "Circle", + style = MaterialTheme.typography.bodyLarge + ) + }, + onClick = { + selectedHead = "Circle" + selectHeadMenuExpaned = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + trailingIcon = { + Image( + modifier = Modifier.size(18.dp), + painter = painterResource(Res.drawable.circle), + contentDescription = null + ) + } + ) + DropdownMenuItem( + text = { + Text( + text = "Square", + style = MaterialTheme.typography.bodyLarge + ) + }, + onClick = { + selectedHead = "Square" + selectHeadMenuExpaned = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + trailingIcon = { + Image( + modifier = Modifier.size(18.dp), + painter = painterResource(Res.drawable.square), + contentDescription = null + ) + } + ) + DropdownMenuItem( + text = { + Text( + text = "Rounded Square", + style = MaterialTheme.typography.bodyLarge + ) + }, + onClick = { + selectedHead = "Rounded Square" + selectHeadMenuExpaned = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + trailingIcon = { + Image( + modifier = Modifier.size(18.dp), + painter = painterResource(Res.drawable.rounded_square), + contentDescription = null + ) + } + ) + } + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Target shape", modifier = Modifier.width(100.dp)) + ExposedDropdownMenuBox( + expanded = selectTargetShapeMenuExpaned, + onExpandedChange = { selectTargetShapeMenuExpaned = it }) { + OutlinedTextField( + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true), + value = targetShapeName, + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { }, + trailingIcon = { + Image( + modifier = Modifier.size(18.dp), + painter = painterResource(targetShapeMap[targetShapeName]!!), + contentDescription = null + ) + }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + ) + ExposedDropdownMenu( + expanded = selectTargetShapeMenuExpaned, + onDismissRequest = { selectTargetShapeMenuExpaned = false } + ) { + DropdownMenuItem( + text = { + Text( + text = "Circle", + style = MaterialTheme.typography.bodyLarge + ) + }, + onClick = { + targetShape = TargetShape.CIRCLE + selectTargetShapeMenuExpaned = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + trailingIcon = { + Image( + modifier = Modifier.size(18.dp), + painter = painterResource(Res.drawable.circle), + contentDescription = null + ) + } + ) + DropdownMenuItem( + text = { + Text( + text = "Rectangle", + style = MaterialTheme.typography.bodyLarge + ) + }, + onClick = { + targetShape = TargetShape.RECTANGLE + selectTargetShapeMenuExpaned = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + trailingIcon = { + Image( + modifier = Modifier.size(18.dp), + painter = painterResource(Res.drawable.square), + contentDescription = null + ) + } + ) + DropdownMenuItem( + text = { + Text( + text = "Rounded Rectangle", + style = MaterialTheme.typography.bodyLarge + ) + }, + onClick = { + targetShape = TargetShape.ROUNDED_RECTANGLE + selectTargetShapeMenuExpaned = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + trailingIcon = { + Image( + modifier = Modifier.size(18.dp), + painter = painterResource(Res.drawable.rounded_square), + contentDescription = null + ) + } + ) + } + } + } + AnimatedVisibility(!isShowcasing, enter = fadeIn(), exit = fadeOut()) { + Column{ + AnimatedVisibility(visible = !useTargetShowcaseLayout, enter = fadeIn(), exit = fadeOut()) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Line thickness: ") + Slider( + value = lineThinckness.toFloat(), + onValueChange = { lineThinckness = it.toInt() }, + valueRange = 1f..10f, + thumb = { + Label( + label = { + PlainTooltip( + modifier = Modifier + .requiredSize(45.dp, 25.dp) + .wrapContentWidth() + ) { + Text("${lineThinckness}dp") + } + } + ) { + Text(modifier = Modifier.drawBehind { + drawCircle( + color = Color.LightGray, + radius = 20.dp.toPx() + ) + }, text = "${lineThinckness}dp") + } + } + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Message corner radius: ") + Slider( + value = msgCornerRadius.toFloat(), + onValueChange = { msgCornerRadius = it.toInt() }, + valueRange = 0f..45f, + thumb = { + Label( + label = { + PlainTooltip( + modifier = Modifier + .requiredSize(45.dp, 25.dp) + .wrapContentWidth() + ) { + Text("${msgCornerRadius}dp") + } + } + ) { + Text(modifier = Modifier.drawBehind { + drawCircle( + color = Color.LightGray, + radius = 20.dp.toPx() + ) + }, text = "${msgCornerRadius}dp") + } + } + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Head size: ") + Slider( + value = headSize, + onValueChange = { headSize = it }, + valueRange = 10f..45f, + thumb = { + Label( + label = { + PlainTooltip( + modifier = Modifier + .requiredSize(45.dp, 25.dp) + .wrapContentWidth() + ) { + Text("${headSize}") + } + } + ) { + Text(modifier = Modifier.drawBehind { + drawCircle( + color = Color.LightGray, + radius = 20.dp.toPx() + ) + }, text = headSize.roundToInt().toString()) + } + } + ) + } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Animation time: ") + Slider( + value = durationSliderValue.toFloat(), + onValueChange = { durationSliderValue = it.roundToInt() }, + onValueChangeFinished = {animationDuration = durationSliderValue.div(3)}, + steps = 24, + valueRange = 500f..3000f, + thumb = { + Label( + label = { + PlainTooltip( + modifier = Modifier + .requiredSize(45.dp, 25.dp) + .wrapContentWidth() + ) { + Text("${durationSliderValue}") + } + } + ) { + Text(modifier = Modifier.drawBehind { + drawRoundRect( + topLeft = Offset(-10f,0f), + color = Color.LightGray, + size = Size(70.dp.toPx(),25.dp.toPx()), + cornerRadius = CornerRadius(10f,10f) + ) + }, text = "$durationSliderValue ms") + } + } + ) + } + Row( + Modifier.selectable( + selected = animateMsg, + onClick = { animateMsg = !animateMsg }, + role = Role.RadioButton + ), verticalAlignment = Alignment.CenterVertically + ) { + Text("Animate msg") + Checkbox(checked = animateMsg, onCheckedChange = { animateMsg = it }) + } + Crossfade (useTargetShowcaseLayout) { + if (it){ + Row( + modifier = Modifier.selectable( + selected = animateToNextTarget, + onClick = { animateToNextTarget = !animateToNextTarget }, + role = Role.Checkbox + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Animate to next target") + Checkbox( + checked = animateToNextTarget, + onCheckedChange = { animateToNextTarget = it } + ) + } + }else{ + Row( + Modifier.selectable( + selected = animateHead, + onClick = { if (!useTargetShowcaseLayout) animateHead = !animateHead }, + role = Role.RadioButton, + enabled = !useTargetShowcaseLayout + ), verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Animate arrow head", + color = if (useTargetShowcaseLayout) Color.Gray else Color.Unspecified + ) + Checkbox( + checked = animateHead, + onCheckedChange = { if (!useTargetShowcaseLayout) animateHead = it }, + enabled = !useTargetShowcaseLayout + ) + } + } + } + } + } + Row { + Button( + onClick = { + coroutineScope.launch { + showGreeting( + ShowcaseMsg( + "👋Hello this is a greeting \n\nGreeting is what usually is displayed before showcasing, it doesn't target any view but is used to display a message to the user 🧐\n\njust like this one.", + textStyle = TextStyle(color = Color.White, textAlign = TextAlign.Center) + ) + ) + } + }) { + Text("Show greeting!") + } + Spacer(Modifier.width(8.dp)) + Button( + modifier = Modifier.showcase( + 3, message = ShowcaseMsg( + text = "Click here to showcase the target selected above.", + msgBackground = Color.Transparent, + textStyle = TextStyle(color = Color.White), + roundedCorner = msgCornerRadius.dp, + arrow = Arrow( + animSize = animateHead, + head = headType, + targetFrom = Side.Top, + headSize = headSize, + animationDuration = animationDuration + ), + enterAnim = if (animateMsg) MsgAnimation.FadeInOut(animationDuration) else MsgAnimation.None + ) + ), + onClick = { + val indexToShowcase = targets[selectedTarget]!! + coroutineScope.launch { + showcaseItem(indexToShowcase) + } + }) { + Text("Showcase!") + } + } + + Button(onClick = { + isShowcasing = true + }) { + Text("Subsequent Showcase!") + } + } + Box() { + SnackbarHost( + snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) + Row( + Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text("By Tahaak67") + TextButton(onClick = { + if (!openUrl("https://github.com/tahaak67")) { + clipboardManager.setText(buildAnnotatedString { append("https://github.com/tahaak67") }) + coroutineScope.launch { snackbarHostState.showSnackbar("Link copied") } + } + }) { + Text("Github") + } + TextButton(onClick = { + if (!openUrl("https://www.linkedin.com/in/tahabenly/")) { + clipboardManager.setText(buildAnnotatedString { append("https://www.linkedin.com/in/tahabenly/") }) + coroutineScope.launch { snackbarHostState.showSnackbar("Link copied") } + } + }) { + Text("Linkedin") + } + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + TextButton(onClick = { + if (!openUrl("https://github.com/tahaak67/ShowcaseLayoutCompose")) { + clipboardManager.setText(buildAnnotatedString { append("https://github.com/tahaak67/ShowcaseLayoutCompose") }) + coroutineScope.launch { snackbarHostState.showSnackbar("Link copied") } + } + }) { + Text("Showcase Layout Compose") + } + } + } + if (finishedSubsequentShowcase){ + coroutineScope.launch { + delay(500) + showGreeting(ShowcaseMsg(buildAnnotatedString { + withStyle(ParagraphStyle(textAlign = TextAlign.Center)){ + append("That's a lot of useful information!,\n ") + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append("i know ") + pop() + append(" i hope you where taking notes 📝 🫠") + } + }, TextStyle(color = Color.White))) + } + finishedSubsequentShowcase = false + } + registerEventListener(object: ShowcaseEventListener { + override fun onEvent(level: Level, event: String) { + println("$level: $event") + } + }) + } + } +} + +@Preview +@Composable +fun AppPreview() { + MyTheme { + App(openUrl = { false }) + } +} diff --git a/composeApp/src/commonMain/kotlin/Color.kt b/composeApp/src/commonMain/kotlin/Color.kt new file mode 100644 index 0000000..0fbb806 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/Color.kt @@ -0,0 +1,68 @@ +package ly.com.tahaben.showcaselayoutcompose.ui.theme + +import androidx.compose.ui.graphics.Color + +val md_theme_light_primary = Color(0xFF006971) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFF83F3FF) +val md_theme_light_onPrimaryContainer = Color(0xFF002022) +val md_theme_light_secondary = Color(0xFF6D5E00) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFFEE25C) +val md_theme_light_onSecondaryContainer = Color(0xFF211B00) +val md_theme_light_tertiary = Color(0xFF0F61A4) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFD2E4FF) +val md_theme_light_onTertiaryContainer = Color(0xFF001C37) +val md_theme_light_error = Color(0xFFBA1A1A) +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_onErrorContainer = Color(0xFF410002) +val md_theme_light_background = Color(0xFFFAFDFD) +val md_theme_light_onBackground = Color(0xFF191C1D) +val md_theme_light_surface = Color(0xFFFAFDFD) +val md_theme_light_onSurface = Color(0xFF191C1D) +val md_theme_light_surfaceVariant = Color(0xFFDAE4E5) +val md_theme_light_onSurfaceVariant = Color(0xFF3F484A) +val md_theme_light_outline = Color(0xFF6F797A) +val md_theme_light_inverseOnSurface = Color(0xFFEFF1F1) +val md_theme_light_inverseSurface = Color(0xFF2D3131) +val md_theme_light_inversePrimary = Color(0xFF4DD9E6) +val md_theme_light_shadow = Color(0xFF000000) +val md_theme_light_surfaceTint = Color(0xFF006971) +val md_theme_light_outlineVariant = Color(0xFFBEC8C9) +val md_theme_light_scrim = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFF4DD9E6) +val md_theme_dark_onPrimary = Color(0xFF00363B) +val md_theme_dark_primaryContainer = Color(0xFF004F55) +val md_theme_dark_onPrimaryContainer = Color(0xFF83F3FF) +val md_theme_dark_secondary = Color(0xFFE0C642) +val md_theme_dark_onSecondary = Color(0xFF393000) +val md_theme_dark_secondaryContainer = Color(0xFF534600) +val md_theme_dark_onSecondaryContainer = Color(0xFFFEE25C) +val md_theme_dark_tertiary = Color(0xFFA1C9FF) +val md_theme_dark_onTertiary = Color(0xFF00325A) +val md_theme_dark_tertiaryContainer = Color(0xFF004880) +val md_theme_dark_onTertiaryContainer = Color(0xFFD2E4FF) +val md_theme_dark_error = Color(0xFFFFB4AB) +val md_theme_dark_errorContainer = Color(0xFF93000A) +val md_theme_dark_onError = Color(0xFF690005) +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val md_theme_dark_background = Color(0xFF191C1D) +val md_theme_dark_onBackground = Color(0xFFE0E3E3) +val md_theme_dark_surface = Color(0xFF191C1D) +val md_theme_dark_onSurface = Color(0xFFE0E3E3) +val md_theme_dark_surfaceVariant = Color(0xFF3F484A) +val md_theme_dark_onSurfaceVariant = Color(0xFFBEC8C9) +val md_theme_dark_outline = Color(0xFF899294) +val md_theme_dark_inverseOnSurface = Color(0xFF191C1D) +val md_theme_dark_inverseSurface = Color(0xFFE0E3E3) +val md_theme_dark_inversePrimary = Color(0xFF006971) +val md_theme_dark_shadow = Color(0xFF000000) +val md_theme_dark_surfaceTint = Color(0xFF4DD9E6) +val md_theme_dark_outlineVariant = Color(0xFF3F484A) +val md_theme_dark_scrim = Color(0xFF000000) + + +val seed = Color(0xFF009AA5) diff --git a/composeApp/src/commonMain/kotlin/Greeting.kt b/composeApp/src/commonMain/kotlin/Greeting.kt new file mode 100644 index 0000000..887d835 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/Greeting.kt @@ -0,0 +1,7 @@ +class Greeting { + private val platform = getPlatform() + + fun greet(): String { + return "Hello, ${platform.name}!" + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/OpenUrl.kt b/composeApp/src/commonMain/kotlin/OpenUrl.kt new file mode 100644 index 0000000..c053386 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/OpenUrl.kt @@ -0,0 +1,4 @@ + +interface UrlLauncher { + fun openUrl(url: String): Boolean +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/Platform.kt b/composeApp/src/commonMain/kotlin/Platform.kt new file mode 100644 index 0000000..87ca3ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/Platform.kt @@ -0,0 +1,5 @@ +interface Platform { + val name: String +} + +expect fun getPlatform(): Platform \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ShowcaseLayoutWrapper.kt b/composeApp/src/commonMain/kotlin/ShowcaseLayoutWrapper.kt new file mode 100644 index 0000000..f583498 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ShowcaseLayoutWrapper.kt @@ -0,0 +1,68 @@ +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import ly.com.tahaben.showcase_layout_compose.model.ShowcaseMsg +import ly.com.tahaben.showcase_layout_compose.model.TargetShape +import ly.com.tahaben.showcase_layout_compose.ui.ShowcaseLayout +import ly.com.tahaben.showcase_layout_compose.ui.ShowcaseScope +import ly.com.tahaben.showcase_layout_compose.ui.TargetShowcaseLayout + +/** + * A wrapper around ShowcaseLayout and TargetShowcaseLayout that allows switching between them. + * + * @param useTargetShowcaseLayout whether to use TargetShowcaseLayout or ShowcaseLayout + * @param isShowcasing to determine if showcase is starting or not. + * @param isDarkLayout if true the showcase view will be white instead of black. + * @param initIndex the initial value of counter, set this to 1 if you don't want a greeting screen before showcasing target. + * @param animationDuration total animation time taken when switching from current to next target in milliseconds. + * @param onFinish what happens when all items are showcased. + * @param greeting greeting message to be shown before showcasing the first composable, leave [initIndex] at 0 if you want to use this. + * @param lineThickness thickness of the arrow line in dp. + * @param targetShape the shape of the target highlight (RECTANGLE, CIRCLE, or ROUNDED_RECTANGLE). + * @param cornerRadius the corner radius for the ROUNDED_RECTANGLE shape in dp. + * @param animateToNextTarget if true, the target shape will animate smoothly from one target to the next when the index changes (only used in TargetShowcaseLayout). + */ +@Composable +fun ShowcaseLayoutWrapper( + useTargetShowcaseLayout: Boolean, + isShowcasing: Boolean, + isDarkLayout: Boolean = false, + initIndex: Int = 0, + animationDuration: Int = 1000, + onFinish: () -> Unit, + greeting: ShowcaseMsg? = null, + lineThickness: Dp = 5.dp, + targetShape: TargetShape = TargetShape.RECTANGLE, + cornerRadius: Dp = 16.dp, + animateToNextTarget: Boolean = true, + content: @Composable ShowcaseScope.() -> Unit +) { + if (useTargetShowcaseLayout) { + TargetShowcaseLayout( + isShowcasing = isShowcasing, + isDarkLayout = isDarkLayout, + initIndex = initIndex, + animationDuration = animationDuration, + onFinish = onFinish, + greeting = greeting, + lineThickness = lineThickness, + targetShape = targetShape, + cornerRadius = cornerRadius, + animateToNextTarget = animateToNextTarget, + content = content + ) + } else { + ShowcaseLayout( + isShowcasing = isShowcasing, + isDarkLayout = isDarkLayout, + initIndex = initIndex, + animationDuration = animationDuration, + onFinish = onFinish, + greeting = greeting, + lineThickness = lineThickness, + targetShape = targetShape, + cornerRadius = cornerRadius, + content = content + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/Theme.kt b/composeApp/src/commonMain/kotlin/Theme.kt new file mode 100644 index 0000000..30b1584 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/Theme.kt @@ -0,0 +1,145 @@ +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_background +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_error +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_errorContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_inverseOnSurface +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_inversePrimary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_inverseSurface +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onBackground +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onError +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onErrorContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onPrimary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onPrimaryContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onSecondary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onSecondaryContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onSurface +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onSurfaceVariant +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onTertiary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_onTertiaryContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_outline +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_outlineVariant +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_primary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_primaryContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_scrim +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_secondary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_secondaryContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_surface +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_surfaceTint +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_surfaceVariant +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_tertiary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_dark_tertiaryContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_background +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_error +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_errorContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_inverseOnSurface +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_inversePrimary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_inverseSurface +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onBackground +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onError +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onErrorContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onPrimary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onPrimaryContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onSecondary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onSecondaryContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onSurface +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onSurfaceVariant +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onTertiary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_onTertiaryContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_outline +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_outlineVariant +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_primary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_primaryContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_scrim +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_secondary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_secondaryContainer +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_surface +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_surfaceTint +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_surfaceVariant +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_tertiary +import ly.com.tahaben.showcaselayoutcompose.ui.theme.md_theme_light_tertiaryContainer + + +private val LightColors = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, + ) + + +private val DarkColors = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, + ) + +@Composable +fun MyTheme( + useDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable() () -> Unit +) { + val colors = if (!useDarkTheme) { + LightColors + } else { + DarkColors + } + + androidx.compose.material3.MaterialTheme( + colorScheme = colors, + content = content + ) +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/Main.kt b/composeApp/src/desktopMain/kotlin/Main.kt new file mode 100644 index 0000000..03a322a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/Main.kt @@ -0,0 +1,8 @@ +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application + +fun main() = application { + Window(onCloseRequest = ::exitApplication, title = "slc test") { + App(openUrl = UrlLuancherDesktop()::openUrl) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/OpenUrl.kt b/composeApp/src/desktopMain/kotlin/OpenUrl.kt new file mode 100644 index 0000000..d169d51 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/OpenUrl.kt @@ -0,0 +1,15 @@ +import java.awt.Desktop +import java.net.URI + +class UrlLuancherDesktop(): UrlLauncher{ + override fun openUrl(url: String): Boolean { + return try { + Desktop.getDesktop().browse(URI(url)) + true + } catch (e: UnsupportedOperationException) { + + false + } + } + +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/Platform.jvm.kt b/composeApp/src/desktopMain/kotlin/Platform.jvm.kt new file mode 100644 index 0000000..5cacd96 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/Platform.jvm.kt @@ -0,0 +1,6 @@ +class JVMPlatform: Platform { + override val name: String = "Java ${System.getProperty("java.version")}" +} + +actual fun getPlatform(): Platform = JVMPlatform() + diff --git a/composeApp/src/iosMain/kotlin/MainViewController.kt b/composeApp/src/iosMain/kotlin/MainViewController.kt new file mode 100644 index 0000000..f9ab8e6 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/MainViewController.kt @@ -0,0 +1,3 @@ +import androidx.compose.ui.window.ComposeUIViewController + +fun MainViewController() = ComposeUIViewController { App(openUrl = UrlLauncherIos()::openUrl) } \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/OpenUrl.kt b/composeApp/src/iosMain/kotlin/OpenUrl.kt new file mode 100644 index 0000000..bc61cb7 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/OpenUrl.kt @@ -0,0 +1,17 @@ +import platform.Foundation.NSURL +import platform.UIKit.UIApplication + +class UrlLauncherIos: UrlLauncher{ + override fun openUrl(url: String): Boolean{ + val nsurl = NSURL.URLWithString(url) ?: return false + return if (UIApplication.sharedApplication.canOpenURL(nsurl)) { + UIApplication.sharedApplication.openURL(nsurl) + true + } else { + // Handle case where the app can't open the URL (optional) + false + } + } +} + + \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/Platform.ios.kt b/composeApp/src/iosMain/kotlin/Platform.ios.kt new file mode 100644 index 0000000..5cef987 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/Platform.ios.kt @@ -0,0 +1,7 @@ +import platform.UIKit.UIDevice + +class IOSPlatform: Platform { + override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion +} + +actual fun getPlatform(): Platform = IOSPlatform() \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/Platform.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/Platform.wasmJs.kt new file mode 100644 index 0000000..57b2e11 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/Platform.wasmJs.kt @@ -0,0 +1,5 @@ +class WasmPlatform: Platform { + override val name: String = "Web with Kotlin/Wasm" +} + +actual fun getPlatform(): Platform = WasmPlatform() \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/Util.kt b/composeApp/src/wasmJsMain/kotlin/Util.kt new file mode 100644 index 0000000..e826706 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/Util.kt @@ -0,0 +1,14 @@ +external fun openUrlWeb(url: String) +class UrlLauncherWeb() : UrlLauncher { + override fun openUrl(url: String): Boolean { + return try { + openUrlWeb(url) + true + } catch (ex: Exception) { + + false + } + } +} + +external fun onLoadFinished() \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/kotlin/main.kt b/composeApp/src/wasmJsMain/kotlin/main.kt new file mode 100644 index 0000000..337e677 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/main.kt @@ -0,0 +1,21 @@ +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.Font +import androidx.compose.ui.window.CanvasBasedWindow +import showcase_layout_compose_kmp.composeapp.generated.resources.Res + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + CanvasBasedWindow(canvasElementId = "ComposeTarget") { + val fontFamilyResolver = LocalFontFamilyResolver.current + + LaunchedEffect(Unit) { + val notoEmojisBytes = Res.readBytes("/font/noto_color_emoji_regular.ttf") + val fontFamily = FontFamily(listOf(Font("NotoColorEmoji", notoEmojisBytes))) + fontFamilyResolver.preload(fontFamily) + } + App(openUrl = UrlLauncherWeb()::openUrl , onWebLoadFinish = ::onLoadFinished) + } +} \ No newline at end of file diff --git a/composeApp/src/wasmJsMain/resources/index.html b/composeApp/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..d161b07 --- /dev/null +++ b/composeApp/src/wasmJsMain/resources/index.html @@ -0,0 +1,54 @@ + + + + + Showcase Layout Compose Demo + + + + + +
+ +
+
+ + + + + diff --git a/composeApp/src/wasmJsMain/resources/util.js b/composeApp/src/wasmJsMain/resources/util.js new file mode 100644 index 0000000..3116a04 --- /dev/null +++ b/composeApp/src/wasmJsMain/resources/util.js @@ -0,0 +1,7 @@ +function openUrlWeb(url) { + window.open(url, '_blank'); // Open URL in a new tab +} + +function onLoadFinished() { + document.dispatchEvent(new Event("app-loaded")); +} diff --git a/convention-plugins/src/main/kotlin/convention.publication.gradle.kts b/convention-plugins/src/main/kotlin/convention.publication.gradle.kts index 59333c2..03ca2eb 100644 --- a/convention-plugins/src/main/kotlin/convention.publication.gradle.kts +++ b/convention-plugins/src/main/kotlin/convention.publication.gradle.kts @@ -1,4 +1,4 @@ -import java.util.Properties +import java.util.* plugins { `maven-publish` @@ -14,7 +14,7 @@ ext["ossrhUsername"] = null ext["ossrhPassword"] = null val publishGroupId: String = "ly.com.tahaben" -val publishVersion: String = "1.0.5" +val publishVersion: String = "1.0.8" val publishArtifactId: String = "showcase-layout-compose" @@ -52,7 +52,7 @@ publishing { repositories { maven { name = "sonatype" - setUrl("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") + setUrl("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") credentials { username = getExtraString("ossrhUsername") password = getExtraString("ossrhPassword") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..e3e33fc --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,40 @@ +[versions] +agp = "8.2.2" +android-compileSdk = "35" +android-minSdk = "24" +android-targetSdk = "35" +androidx-activityCompose = "1.10.1" +androidx-appcompat = "1.7.1" +androidx-constraintlayout = "2.2.1" +androidx-core-ktx = "1.16.0" +androidx-espresso-core = "3.6.1" +androidx-material = "1.12.0" +androidx-test-junit = "1.2.1" +compose = "1.8.2" +compose-plugin = "1.8.1" +junit = "4.13.2" +kotlin = "2.1.21" +nexusPublishing = "1.3.0" + +[libraries] +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } +androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } +androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } +compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } +compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +nexusPublishing = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexusPublishing" } \ No newline at end of file diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig new file mode 100644 index 0000000..479903c --- /dev/null +++ b/iosApp/Configuration/Config.xcconfig @@ -0,0 +1,3 @@ +TEAM_ID= +BUNDLE_ID=ly.com.tahaben.showcaselayoutcomposekmp +APP_NAME=Showcase layout compose demo \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..315d28a --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,398 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; }; + 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; + 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; + 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; + 7555FF7B242A565900829871 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = iosApp.app; path = "slc test.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + B92378962B6B1156000C7307 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 058557D7273AAEEB004C7B11 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 42799AB246E5F90AF97AA0EF /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 7555FF72242A565900829871 = { + isa = PBXGroup; + children = ( + AB1DB47929225F7C00F7AF9C /* Configuration */, + 7555FF7D242A565900829871 /* iosApp */, + 7555FF7C242A565900829871 /* Products */, + 42799AB246E5F90AF97AA0EF /* Frameworks */, + ); + sourceTree = ""; + }; + 7555FF7C242A565900829871 /* Products */ = { + isa = PBXGroup; + children = ( + 7555FF7B242A565900829871 /* iosApp.app */, + ); + name = Products; + sourceTree = ""; + }; + 7555FF7D242A565900829871 /* iosApp */ = { + isa = PBXGroup; + children = ( + 058557BA273AAA24004C7B11 /* Assets.xcassets */, + 7555FF82242A565900829871 /* ContentView.swift */, + 7555FF8C242A565B00829871 /* Info.plist */, + 2152FB032600AC8F00CF470E /* iOSApp.swift */, + 058557D7273AAEEB004C7B11 /* Preview Content */, + ); + path = iosApp; + sourceTree = ""; + }; + AB1DB47929225F7C00F7AF9C /* Configuration */ = { + isa = PBXGroup; + children = ( + AB3632DC29227652001CCB65 /* Config.xcconfig */, + ); + path = Configuration; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7555FF7A242A565900829871 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */, + 7555FF77242A565900829871 /* Sources */, + B92378962B6B1156000C7307 /* Frameworks */, + 7555FF79242A565900829871 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = 7555FF7B242A565900829871 /* iosApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7555FF73242A565900829871 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1130; + LastUpgradeCheck = 1130; + ORGANIZATIONNAME = orgName; + TargetAttributes = { + 7555FF7A242A565900829871 = { + CreatedOnToolsVersion = 11.3.1; + }; + }; + }; + buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */; + compatibilityVersion = "Xcode 12.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7555FF72242A565900829871; + packageReferences = ( + ); + productRefGroup = 7555FF7C242A565900829871 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7555FF7A242A565900829871 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7555FF79242A565900829871 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, + 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Compile Kotlin Framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7555FF77242A565900829871 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, + 7555FF83242A565900829871 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 7555FFA3242A565B00829871 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.3; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 7555FFA4242A565B00829871 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.3; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 7555FFA6242A565B00829871 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + INFOPLIST_FILE = iosApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.3; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + composeApp, + ); + PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; + PRODUCT_NAME = "${APP_NAME}"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7555FFA7242A565B00829871 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + INFOPLIST_FILE = iosApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.3; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + composeApp, + ); + PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; + PRODUCT_NAME = "${APP_NAME}"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7555FFA3242A565B00829871 /* Debug */, + 7555FFA4242A565B00829871 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7555FFA6242A565B00829871 /* Debug */, + 7555FFA7242A565B00829871 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 7555FF73242A565900829871 /* Project object */; +} diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..ee7e3ca --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..8edf56e --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "app-icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png new file mode 100644 index 0000000..53fc536 Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png differ diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..4aa7c53 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift new file mode 100644 index 0000000..3cd5c32 --- /dev/null +++ b/iosApp/iosApp/ContentView.swift @@ -0,0 +1,21 @@ +import UIKit +import SwiftUI +import ComposeApp + +struct ComposeView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + MainViewControllerKt.MainViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +struct ContentView: View { + var body: some View { + ComposeView() + .ignoresSafeArea(.keyboard) // Compose has own keyboard handler + } +} + + + diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..412e378 --- /dev/null +++ b/iosApp/iosApp/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..4aa7c53 --- /dev/null +++ b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift new file mode 100644 index 0000000..b7bf2f4 --- /dev/null +++ b/iosApp/iosApp/iOSApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct iOSApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/metadata/screenshots/screenshot-targetshocase-circle.png b/metadata/screenshots/screenshot-targetshocase-circle.png new file mode 100644 index 0000000..bc4be67 Binary files /dev/null and b/metadata/screenshots/screenshot-targetshocase-circle.png differ diff --git a/metadata/screenshots/screenshot-targetshocase-rect.png b/metadata/screenshots/screenshot-targetshocase-rect.png new file mode 100644 index 0000000..ca8ccb0 Binary files /dev/null and b/metadata/screenshots/screenshot-targetshocase-rect.png differ diff --git a/metadata/screenshots/screenshot-targetshocase-roundrect.png b/metadata/screenshots/screenshot-targetshocase-roundrect.png new file mode 100644 index 0000000..5922027 Binary files /dev/null and b/metadata/screenshots/screenshot-targetshocase-roundrect.png differ diff --git a/settings.gradle b/settings.gradle.kts similarity index 59% rename from settings.gradle rename to settings.gradle.kts index abe2b81..5f892e2 100644 --- a/settings.gradle +++ b/settings.gradle.kts @@ -1,9 +1,10 @@ - +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") pluginManagement { repositories { google() gradlePluginPortal() mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } } plugins { @@ -13,9 +14,11 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } } -rootProject.name = "Showcase Layout Compose" +rootProject.name = "Showcase-Layout-Compose-KMP" include (":app") include (":showcase-layout-compose") includeBuild("convention-plugins") +include (":composeApp") diff --git a/showcase-layout-compose/build.gradle.kts b/showcase-layout-compose/build.gradle.kts index 396131e..1f02484 100644 --- a/showcase-layout-compose/build.gradle.kts +++ b/showcase-layout-compose/build.gradle.kts @@ -1,10 +1,10 @@ -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig plugins { - kotlin("multiplatform") - id("com.android.library") - id("org.jetbrains.compose") + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) id("convention.publication") } @@ -18,9 +18,9 @@ kotlin { iosArm64() iosSimulatorArm64() - @OptIn(ExperimentalWasmDsl::class) + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) wasmJs { - moduleName = "composeApp" + outputModuleName = "composeApp" browser { commonWebpackConfig { outputFileName = "composeApp.js" @@ -65,7 +65,7 @@ kotlin { } android { - compileSdk = 34 + compileSdk = 35 namespace = "ly.com.tahaben.showcaselayoutcompose" compileOptions { @@ -76,8 +76,3 @@ android { jvmToolchain(17) } } - - -compose.experimental { - web.application {} -} \ No newline at end of file diff --git a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/ShowcaseData.kt b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/ShowcaseData.kt index 4add387..b54c5fb 100644 --- a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/ShowcaseData.kt +++ b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/ShowcaseData.kt @@ -1,6 +1,7 @@ package ly.com.tahaben.showcase_layout_compose.model import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.unit.IntSize /** @@ -23,5 +24,6 @@ import androidx.compose.ui.unit.IntSize data class ShowcaseData( val size: IntSize, val position: Offset, - val message: ShowcaseMsg? = null + val message: ShowcaseMsg? = null, + val coordinates: LayoutCoordinates? = null ) diff --git a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/TargetShape.kt b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/TargetShape.kt new file mode 100644 index 0000000..00dfecb --- /dev/null +++ b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/model/TargetShape.kt @@ -0,0 +1,12 @@ +package ly.com.tahaben.showcase_layout_compose.model + +/** + * Enum class that defines the shape of the target highlight in the TargetShowcaseLayout. + * + * CIRCLE: Draws a circular highlight around the target. + * RECTANGLE: Draws a rectangular highlight around the target. + * ROUNDED_RECTANGLE: Draws a rounded rectangular highlight around the target. + */ +enum class TargetShape { + CIRCLE, RECTANGLE, ROUNDED_RECTANGLE +} diff --git a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseLayout.kt b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseLayout.kt index c0a0996..186aeb5 100644 --- a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseLayout.kt +++ b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseLayout.kt @@ -12,18 +12,11 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path @@ -40,27 +33,17 @@ import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.toSize +import androidx.compose.ui.unit.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import ly.com.tahaben.showcase_layout_compose.domain.Level import ly.com.tahaben.showcase_layout_compose.domain.ShowcaseEventListener -import ly.com.tahaben.showcase_layout_compose.model.Arrow -import ly.com.tahaben.showcase_layout_compose.model.Gravity -import ly.com.tahaben.showcase_layout_compose.model.Head -import ly.com.tahaben.showcase_layout_compose.model.MsgAnimation -import ly.com.tahaben.showcase_layout_compose.model.ShowcaseData -import ly.com.tahaben.showcase_layout_compose.model.ShowcaseMsg -import ly.com.tahaben.showcase_layout_compose.model.Side +import ly.com.tahaben.showcase_layout_compose.model.* import kotlin.math.PI import kotlin.math.atan2 +import kotlin.math.max /** * Copyright 2023 Taha Ben Ashur (tahaak67) @@ -92,6 +75,8 @@ private const val INDEX_RESET_DELAY = 250L * @param onFinish what happens when all items are showcased. * @param greeting greeting message to be shown before showcasing the first composable, leave [initIndex] at 0 if you want to use this. * @param lineThickness thickness of the arrow line in dp. + * @param targetShape the shape of the target highlight (RECTANGLE, CIRCLE, or ROUNDED_RECTANGLE). + * @param cornerRadius the corner radius for the ROUNDED_RECTANGLE shape in dp. **/ @Composable @@ -103,55 +88,53 @@ fun ShowcaseLayout( onFinish: () -> Unit, greeting: ShowcaseMsg? = null, lineThickness: Dp = 5.dp, + targetShape: TargetShape = TargetShape.RECTANGLE, + cornerRadius: Dp = 16.dp, content: @Composable ShowcaseScope.() -> Unit ) { var currentIndex by remember { mutableIntStateOf(initIndex) } + val currentContent by rememberUpdatedState(content) val resetDelay by derivedStateOf { animationDuration.toLong() + INDEX_RESET_DELAY } val scope = ShowcaseScopeImpl(greeting) - scope.content() + scope.currentContent() - var showCasingItem by remember { mutableStateOf(false) } var singleGreetingMsg by remember { mutableStateOf(null) } - var isSingleGreeting by remember { mutableStateOf(false) } - LaunchedEffect(key1 = isShowcasing) { - launch { - scope.showcaseActionFlow.collectLatest { - if (it != null) { - scope.showcaseEventListener?.onEvent( - Level.DEBUG, - TAG + "showcase single item index: $it" - ) - currentIndex = it ?: initIndex - showCasingItem = true - } else { - showCasingItem = false - delay(resetDelay) - currentIndex = initIndex - } + val showcaseItem = scope.showcaseActionFlow.collectAsState() + val showCasingItem by remember { + derivedStateOf { + if (showcaseItem.value != null) { + scope.showcaseEventListener?.onEvent( + Level.DEBUG, + TAG + "showcase single item index: ${showcaseItem.value}" + ) + currentIndex = showcaseItem.value ?: initIndex + true + } else { + false } } - launch { - scope.greetingActionFlow.collectLatest { - if (it != null) { - scope.showcaseEventListener?.onEvent( - Level.DEBUG, - TAG + "showcase single greeting: $it" - ) - currentIndex = 0 - isSingleGreeting = true - } else { - isSingleGreeting = false - delay(resetDelay) - currentIndex = initIndex - } - singleGreetingMsg = it + } + val singleGreeting = scope.greetingActionFlow.collectAsState() + val isSingleGreeting by remember { + derivedStateOf { + if (singleGreeting.value != null) { + scope.showcaseEventListener?.onEvent( + Level.DEBUG, + TAG + "showcase single greeting: ${singleGreeting.value?.text}" + ) + singleGreetingMsg = singleGreeting.value + currentIndex = 0 + true + } else { + false } } } BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val coroutineScope = rememberCoroutineScope() AnimatedVisibility( isShowcasing || showCasingItem || isSingleGreeting, enter = fadeIn(), @@ -178,7 +161,6 @@ fun ShowcaseLayout( val shouldDrawArrow = (message?.arrow != null && isArrowDelayOver) val arrowColor = message?.arrow?.color ?: Color.White val density = LocalDensity.current - val coroutineScope = rememberCoroutineScope() var arrowAnimDuration by remember { mutableStateOf(message?.arrow?.animationDuration) } val animMsgTextAlpha = remember { Animatable(0f) } val animMsgAlpha = remember { Animatable(0f) } @@ -188,6 +170,7 @@ fun ShowcaseLayout( /** to animate current arrow line */ LaunchedEffect(key1 = currentIndex) { + message = scope.getMessageFor(currentIndex) arrowAnimDuration = message?.arrow?.animationDuration isArrowDelayOver = false @@ -210,7 +193,7 @@ fun ShowcaseLayout( launch { message?.arrow?.let { arrow -> /** show the arrow if anim is false */ - if (!arrow.animSize){ + if (!arrow.animSize) { animArrowHead.snapTo(arrow.headSize) } /** move the arrow */ @@ -222,7 +205,7 @@ fun ShowcaseLayout( ) ) /** animate the size of the arrow */ - if (arrow.animSize){ + if (arrow.animSize) { animArrowHead.animateTo( arrow.headSize, tween( @@ -274,21 +257,24 @@ fun ShowcaseLayout( modifier = Modifier .fillMaxSize() .semantics { testTag = "canvas" } - .pointerInput(isShowcasing) { + .pointerInput(Unit) { detectTapGestures { /** detect taps on the screen */ coroutineScope.launch { /** hide current arrow */ arrowAnimDuration?.let { duration -> - if (message?.arrow?.animSize == true){ - animArrowHead.animateTo(0f, tween(duration/2)) + if (message?.arrow?.animSize == true) { + animArrowHead.animateTo(0f, tween(duration / 2)) } launch { animArrow.animateTo(0f, tween(duration / 2)) } // subtracting Float.MIN_VALUE here to avoid a tiny part of the path left on IOS and Desktop - pathPortion.animateTo(0f - Float.MIN_VALUE, tween(duration / 2)) + pathPortion.animateTo( + 0f - Float.MIN_VALUE, + tween(duration / 2) + ) } message?.let { msg -> scope.showcaseEventListener?.onEvent( @@ -314,10 +300,14 @@ fun ShowcaseLayout( } if (showCasingItem) { scope.showcaseItemFinished() + delay(resetDelay) + currentIndex = initIndex return@launch } if (isSingleGreeting) { scope.showGreetingFinished() + delay(resetDelay) + currentIndex = initIndex return@launch } if (currentIndex + 1 < scope.getHashMapSize()) { @@ -348,187 +338,172 @@ fun ShowcaseLayout( onDraw = { /** make transparent background path around the target composable */ - val showcasePath = Path().apply { - lineTo(size.width, 0f) - lineTo(size.width, size.height) - lineTo(offset.x + itemSize.width, size.height) - lineTo(offset.x + itemSize.width, 0f) - moveTo(offset.x + itemSize.width, offset.y + itemSize.height) - lineTo(offset.x + itemSize.width, size.height) - lineTo(0f, size.height) - lineTo(0f, offset.y + itemSize.height) - close() - moveTo(0f, 0f) - lineTo(offset.x, 0f) - lineTo(offset.x, offset.y + itemSize.height) - lineTo(0f, offset.y + itemSize.height) - close() - moveTo(offset.x, 0f) - lineTo(offset.x + itemSize.width, 0f) - lineTo(offset.x + itemSize.width, offset.y) - lineTo(offset.x, offset.y) - close() - } - /** draw the showcasePath */ - drawPath( - path = showcasePath, - color = if (isDarkLayout) Color.White else Color.Black, - alpha = 0.80f, - ) - val hasArrowHead = message?.arrow?.head != null - val arrowHeadMargin = (message?.arrow?.headSize ?: Arrow().headSize) + 25 - - if (currentIndex > 0 && shouldDrawArrow) { - /** draw arrow line */ - val arrowPath = Path().apply { - if (message?.arrow?.curved == true) { - moveTo( - (maxWidth / 2).toPx(), - offset.y + itemSize.height + 200 - ) - val xPoint = if ((offset.x + itemSize.width + 80) > size.width) { - offset.x - 80 - } else { - (offset.x + itemSize.width + 50) + if (currentIndex == 0 || isSingleGreeting) { + // Draw a full canvas without any cutout for greeting or index 0 + drawRect( + color = if (isDarkLayout) Color.White else Color.Black, + alpha = 0.80f, + size = size + ) + } else { + when (targetShape) { + TargetShape.RECTANGLE -> { + // Create a rectangular path around the target + val showcasePath = Path().apply { + lineTo(size.width, 0f) + lineTo(size.width, size.height) + lineTo(offset.x + itemSize.width, size.height) + lineTo(offset.x + itemSize.width, 0f) + moveTo(offset.x + itemSize.width, offset.y + itemSize.height) + lineTo(offset.x + itemSize.width, size.height) + lineTo(0f, size.height) + lineTo(0f, offset.y + itemSize.height) + close() + moveTo(0f, 0f) + lineTo(offset.x, 0f) + lineTo(offset.x, offset.y + itemSize.height) + lineTo(0f, offset.y + itemSize.height) + close() + moveTo(offset.x, 0f) + lineTo(offset.x + itemSize.width, 0f) + lineTo(offset.x + itemSize.width, offset.y) + lineTo(offset.x, offset.y) + close() } - quadraticBezierTo( - (size.width / 2), - offset.y + itemSize.height + 0, - xPoint, - offset.y + (itemSize.height / 2) + /** draw the showcasePath */ + drawPath( + path = showcasePath, + color = if (isDarkLayout) Color.White else Color.Black, + alpha = 0.80f, ) - } else { - when (message?.arrow?.targetFrom) { - Side.Top -> { - moveTo( - offset.x + (itemSize.width / 2), - offset.y - 200 - ) - lineTo( - offset.x + (itemSize.width / 2), - if (hasArrowHead) offset.y - arrowHeadMargin else offset.y - ) - } - - Side.Bottom -> { - moveTo( - offset.x + (itemSize.width / 2), - offset.y + (itemSize.height + 250) - ) - lineTo( - offset.x + (itemSize.width / 2), - if (hasArrowHead) offset.y + itemSize.height + arrowHeadMargin else offset.y + itemSize.height - ) - } + } + TargetShape.CIRCLE -> { + // Calculate the center and radius of the circle + val centerX = offset.x + itemSize.width / 2 + val centerY = offset.y + itemSize.height / 2 + val radius = maxOf(itemSize.width, itemSize.height) / 2 - Side.Left -> { - moveTo( - offset.x - 200, - offset.y + (itemSize.height / 2) - ) - lineTo( - if (hasArrowHead) offset.x - arrowHeadMargin else offset.x, - offset.y + (itemSize.height / 2) - ) - } + // Create paths for the outer and inner areas + val outerPath = Path().apply { + // Draw a rectangle covering the entire canvas + addRect(Rect(0f, 0f, size.width, size.height)) + } - Side.Right -> { - moveTo( - offset.x + (itemSize.width + 200), - offset.y + (itemSize.height / 2) - ) - lineTo( - if (hasArrowHead) offset.x + itemSize.width + arrowHeadMargin else offset.x + itemSize.width, - offset.y + (itemSize.height / 2) - ) - } + // Create a path for the target area (circle) + val targetPath = Path().apply { + addOval(Rect( + centerX - radius, + centerY - radius, + centerX + radius, + centerY + radius + )) + } - null -> Unit + // Create a combined path with a hole + val showcasePath = Path().apply { + addPath(outerPath) + op(outerPath, targetPath, androidx.compose.ui.graphics.PathOperation.Difference) } - } - } - val outPath = Path() - val pos = FloatArray(2) - val tan = FloatArray(2) - PathMeasure().apply { - setPath(arrowPath, false) - getSegment(0f, pathPortion.value * length, outPath, true) - getPosition(pathPortion.value * length).apply { - pos[0] = x - pos[1] = y - } - getTangent(pathPortion.value * length).apply { - tan[0] = x - tan[1] = y + // Draw the path + drawPath( + path = showcasePath, + color = if (isDarkLayout) Color.White else Color.Black, + alpha = 0.80f, + ) } - scope.showcaseEventListener?.onEvent( - Level.VERBOSE, - TAG + "pos:${pos} tan:${tan}" - ) - } - drawPath( - path = outPath, - color = arrowColor, - style = Stroke(width = lineThickness.toPx(), cap = StrokeCap.Round) - ) + TargetShape.ROUNDED_RECTANGLE -> { + // Create paths for the outer and inner areas + val outerPath = Path().apply { + // Draw a rectangle covering the entire canvas + addRect(Rect(0f, 0f, size.width, size.height)) + } - /** draw the arrow head (and rotate if needed) */ - val arrowSize = animArrowHead.value - val x = pos[0] - val y = pos[1] - val degrees = -atan2(tan[0], tan[1]) * (180f / PI.toFloat()) - 180f - scope.showcaseEventListener?.onEvent( - Level.VERBOSE, - TAG + "max canvas: x:${size.width} y:${size.height}" - ) - when (message?.arrow?.head) { - Head.CIRCLE -> { - drawCircle( - center = Offset(x,y), - color = arrowColor, - alpha = animArrow.value, - radius = arrowSize - ) + // Create a path for the target area (rounded rectangle) + val targetPath = Path().apply { + // Top-left corner + moveTo(offset.x + cornerRadius.toPx(), offset.y) - } - Head.TRIANGLE -> { - rotate(degrees = degrees, pivot = Offset(x, y)) { - drawPath( - path = Path().apply { - moveTo(x, y - arrowSize) - lineTo(x - arrowSize, y + arrowSize) - lineTo(x + arrowSize, y + arrowSize) - close() - }, - color = arrowColor, - alpha = animArrow.value + // Top edge and top-right corner + lineTo(offset.x + itemSize.width - cornerRadius.toPx(), offset.y) + arcTo( + rect = Rect( + offset.x + itemSize.width - 2 * cornerRadius.toPx(), + offset.y, + offset.x + itemSize.width, + offset.y + 2 * cornerRadius.toPx() + ), + startAngleDegrees = 270f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Right edge and bottom-right corner + lineTo(offset.x + itemSize.width, offset.y + itemSize.height - cornerRadius.toPx()) + arcTo( + rect = Rect( + offset.x + itemSize.width - 2 * cornerRadius.toPx(), + offset.y + itemSize.height - 2 * cornerRadius.toPx(), + offset.x + itemSize.width, + offset.y + itemSize.height + ), + startAngleDegrees = 0f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Bottom edge and bottom-left corner + lineTo(offset.x + cornerRadius.toPx(), offset.y + itemSize.height) + arcTo( + rect = Rect( + offset.x, + offset.y + itemSize.height - 2 * cornerRadius.toPx(), + offset.x + 2 * cornerRadius.toPx(), + offset.y + itemSize.height + ), + startAngleDegrees = 90f, + sweepAngleDegrees = 90f, + forceMoveTo = false ) + + // Left edge and top-left corner + lineTo(offset.x, offset.y + cornerRadius.toPx()) + arcTo( + rect = Rect( + offset.x, + offset.y, + offset.x + 2 * cornerRadius.toPx(), + offset.y + 2 * cornerRadius.toPx() + ), + startAngleDegrees = 180f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + close() } - } - Head.SQUARE -> { - drawRect( - topLeft = Offset(x - arrowSize.div(2),y - arrowSize.div(2)), - color = arrowColor, - alpha = animArrow.value, - size = Size(arrowSize,arrowSize) - ) - } - Head.ROUND_SQUARE -> { - val radius = arrowSize.div(4) - drawRoundRect( - topLeft = Offset(x - arrowSize.div(2),y - arrowSize.div(2)), - color = arrowColor, - alpha = animArrow.value, - size = Size(arrowSize,arrowSize), - cornerRadius = CornerRadius(radius,radius) + + // Create a combined path with a hole + val showcasePath = Path().apply { + addPath(outerPath) + op(outerPath, targetPath, androidx.compose.ui.graphics.PathOperation.Difference) + } + + // Draw the path + drawPath( + path = showcasePath, + color = if (isDarkLayout) Color.White else Color.Black, + alpha = 0.80f, ) } - null -> Unit - } - } + + // Calculate the center and radius of the circle for target + val centerX = offset.x + itemSize.width / 2 + val centerY = offset.y + itemSize.height / 2 + val radius = maxOf(itemSize.width, itemSize.height) / 2 + message?.let { msg -> /** Create a measurer for the message with limited constraints and a 'Visible' @@ -539,7 +514,7 @@ fun ShowcaseLayout( msg.text, style = msg.textStyle, overflow = TextOverflow.Visible, - constraints = Constraints(0, constraints.maxWidth - 90) + constraints = Constraints(0, max(1, constraints.maxWidth - 90)) ) /** Determine if message will be shown on top or below target */ @@ -547,24 +522,38 @@ fun ShowcaseLayout( if (currentIndex == 0) (size.height / 2) else with(density) { val currentItemYPosition = scope.getPositionFor(currentIndex).y val currentItemHeight = scope.getSizeFor(currentIndex).height + + // Calculate additional offset for circle shape + val additionalOffset = if (targetShape == TargetShape.CIRCLE) { + // Use the radius of the circle as additional offset + val currentItemWidth = scope.getSizeFor(currentIndex).width + val radius = maxOf(currentItemWidth, currentItemHeight) / 2 + // Add some extra margin (50px) to ensure enough space for the arrow + radius + 50 + } else { + 0f + } + + val baseOffset = 230 + additionalOffset + when (msg.gravity) { Gravity.Top -> { - currentItemYPosition - 230 + currentItemYPosition - baseOffset } Gravity.Bottom -> { - currentItemYPosition + currentItemHeight + 230 + currentItemYPosition + currentItemHeight + baseOffset } Gravity.Auto -> { val topPosition = - currentItemYPosition - 230 + currentItemYPosition - baseOffset if (topPosition < 0) { scope.showcaseEventListener?.onEvent( Level.INFO, TAG + "index: $currentIndex Not enough space on top show msg on bottom" ) - currentItemYPosition + currentItemHeight + 230 + currentItemYPosition + currentItemHeight + baseOffset } else { scope.showcaseEventListener?.onEvent( Level.INFO, @@ -589,6 +578,18 @@ fun ShowcaseLayout( } else { val currentItemXPosition = scope.getPositionFor(currentIndex).x val currentItemWidth = scope.getSizeFor(currentIndex).width + val currentItemHeight = scope.getSizeFor(currentIndex).height + + // Calculate additional horizontal offset for circle shape + val additionalHorizontalOffset = if (targetShape == TargetShape.CIRCLE) { + // Use the radius of the circle as additional offset + val radius = maxOf(currentItemWidth, currentItemHeight) / 2 + // Add some extra margin to ensure enough space for the arrow + radius * 0.3f // 30% of radius as extra margin + } else { + 0f + } + val currentItemXMiddlePoint = currentItemXPosition + (currentItemWidth / 2) when { @@ -600,7 +601,12 @@ fun ShowcaseLayout( if ((currentItemXMiddlePoint - messageWidthHalf) < 0) { currentItemXPosition } else { - currentItemXMiddlePoint - messageWidthHalf + // For left side, move message further left + if (msg.arrow?.targetFrom == Side.Right && targetShape == TargetShape.CIRCLE) { + currentItemXMiddlePoint - messageWidthHalf - additionalHorizontalOffset + } else { + currentItemXMiddlePoint - messageWidthHalf + } } } @@ -620,7 +626,12 @@ fun ShowcaseLayout( if (currentItemXMiddlePoint + messageWidthHalf > size.width) { currentItemXPosition + currentItemWidth - textResult.size.width } else { - currentItemXMiddlePoint - messageWidthHalf + // For right side, move message further right + if (msg.arrow?.targetFrom == Side.Left && targetShape == TargetShape.CIRCLE) { + currentItemXMiddlePoint - messageWidthHalf + additionalHorizontalOffset + } else { + currentItemXMiddlePoint - messageWidthHalf + } } } } @@ -632,6 +643,8 @@ fun ShowcaseLayout( textResult.size.width + 36, textResult.size.height + 36 ).toSize() + + // Draw the message card if (msg.roundedCorner == 0.dp) { drawRect( msg.msgBackground ?: Color.Transparent, @@ -648,7 +661,273 @@ fun ShowcaseLayout( alpha = animMsgAlpha.value ) } - drawText(textResult, topLeft = textOffset, alpha = animMsgTextAlpha.value) + + // Draw the arrow if needed + val hasArrowHead = msg.arrow?.head != null + val arrowHeadMargin = (msg.arrow?.headSize ?: Arrow().headSize) + 25 + + if (currentIndex > 0 && shouldDrawArrow) { + /** draw arrow line */ + val arrowPath = Path().apply { + if (msg.arrow?.curved == true) { + // Calculate card center + val cardCenterX = cardOffset.x + cardSize.width / 2 + val cardCenterY = cardOffset.y + cardSize.height / 2 + + // Start from the message card based on targetFrom direction + when (msg.arrow?.targetFrom) { + Side.Top -> { + // Start from bottom center of the message card + moveTo( + cardCenterX, + cardOffset.y + cardSize.height + ) + } + Side.Bottom -> { + // Start from top center of the message card + moveTo( + cardCenterX, + cardOffset.y + ) + } + Side.Left -> { + // Start from right center of the message card + moveTo( + cardOffset.x + cardSize.width, + cardCenterY + ) + } + Side.Right -> { + // Start from left center of the message card + moveTo( + cardOffset.x, + cardCenterY + ) + } + else -> { + // Default to bottom center if targetFrom is not specified + moveTo( + cardCenterX, + cardOffset.y + cardSize.height + ) + } + } + + val xPoint = + if ((offset.x + itemSize.width + 80) > size.width) { + offset.x - 80 + } else { + (offset.x + itemSize.width + 50) + } + quadraticTo( + size.width / 2, offset.y + itemSize.height + 0, + xPoint, + offset.y + (itemSize.height / 2) + ) + } else { + // Calculate card center + val cardCenterX = cardOffset.x + cardSize.width / 2 + val cardCenterY = cardOffset.y + cardSize.height / 2 + + when (msg.arrow?.targetFrom) { + Side.Top -> { + // Start from bottom center of the message card + if (targetShape == TargetShape.CIRCLE) { + moveTo( + cardCenterX, + cardOffset.y + cardSize.height + ) + // For circle, point to the top edge of the circle + lineTo( + centerX, + if (hasArrowHead) centerY - radius - arrowHeadMargin else centerY - radius + ) + } else { + moveTo( + offset.x + (itemSize.width / 2), + offset.y - 180 + ) + lineTo( + offset.x + (itemSize.width / 2), + if (hasArrowHead) offset.y - arrowHeadMargin else offset.y + ) + } + } + + Side.Bottom -> { + // Start from top center of the message card + if (targetShape == TargetShape.CIRCLE) { + moveTo( + cardCenterX, + cardOffset.y + ) + // For circle, point to the bottom edge of the circle + lineTo( + centerX, + if (hasArrowHead) centerY + radius + arrowHeadMargin else centerY + radius + ) + } else { + moveTo( + offset.x + (itemSize.width / 2), + offset.y + (itemSize.height + 200) + ) + lineTo( + offset.x + (itemSize.width / 2), + if (hasArrowHead) offset.y + itemSize.height + arrowHeadMargin else offset.y + itemSize.height + ) + } + } + + Side.Left -> { + // Start from right center of the message card + if (targetShape == TargetShape.CIRCLE) { + moveTo( + cardOffset.x + cardSize.width, + cardCenterY + ) + // For circle, point to the left edge of the circle + lineTo( + if (hasArrowHead) centerX - radius - arrowHeadMargin else centerX - radius, + centerY + ) + } else { + moveTo( + offset.x - 200, + offset.y + (itemSize.height / 2) + ) + lineTo( + if (hasArrowHead) offset.x - arrowHeadMargin else offset.x, + offset.y + (itemSize.height / 2) + ) + } + } + + Side.Right -> { + // Start from left center of the message card + if (targetShape == TargetShape.CIRCLE) { + moveTo( + cardOffset.x, + cardCenterY + ) + // For circle, point to the right edge of the circle + lineTo( + if (hasArrowHead) centerX + radius + arrowHeadMargin else centerX + radius, + centerY + ) + } else { + moveTo( + offset.x + (itemSize.width + 200), + offset.y + (itemSize.height / 2) + ) + lineTo( + if (hasArrowHead) offset.x + itemSize.width + arrowHeadMargin else offset.x + itemSize.width, + offset.y + (itemSize.height / 2) + ) + } + } + + null -> Unit + } + } + } + + val outPath = Path() + val pos = FloatArray(2) + val tan = FloatArray(2) + PathMeasure().apply { + setPath(arrowPath, false) + getSegment(0f, pathPortion.value * length, outPath, true) + getPosition(pathPortion.value * length).apply { + pos[0] = x + pos[1] = y + } + getTangent(pathPortion.value * length).apply { + tan[0] = x + tan[1] = y + } + scope.showcaseEventListener?.onEvent( + Level.VERBOSE, + TAG + "pos:${pos} tan:${tan}" + ) + } + drawPath( + path = outPath, + color = arrowColor, + style = Stroke(width = lineThickness.toPx(), cap = StrokeCap.Round) + ) + + /** draw the arrow head (and rotate if needed) */ + val arrowSize = animArrowHead.value + val x = pos[0] + val y = pos[1] + val degrees = -atan2(tan[0], tan[1]) * (180f / PI.toFloat()) - 180f + scope.showcaseEventListener?.onEvent( + Level.VERBOSE, + TAG + "max canvas: x:${size.width} y:${size.height}" + ) + when (msg.arrow?.head) { + Head.CIRCLE -> { + drawCircle( + center = Offset(x, y), + color = arrowColor, + alpha = animArrow.value, + radius = arrowSize + ) + + } + + Head.TRIANGLE -> { + rotate(degrees = degrees, pivot = Offset(x, y)) { + drawPath( + path = Path().apply { + moveTo(x, y - arrowSize) + lineTo(x - arrowSize, y + arrowSize) + lineTo(x + arrowSize, y + arrowSize) + close() + }, + color = arrowColor, + alpha = animArrow.value + ) + } + } + + Head.SQUARE -> { + drawRect( + topLeft = Offset( + x - arrowSize.div(2), + y - arrowSize.div(2) + ), + color = arrowColor, + alpha = animArrow.value, + size = Size(arrowSize, arrowSize) + ) + } + + Head.ROUND_SQUARE -> { + val radius = arrowSize.div(4) + drawRoundRect( + topLeft = Offset( + x - arrowSize.div(2), + y - arrowSize.div(2) + ), + color = arrowColor, + alpha = animArrow.value, + size = Size(arrowSize, arrowSize), + cornerRadius = CornerRadius(radius, radius) + ) + } + + null -> Unit + + } + } + + // Draw the message text + drawText( + textResult, + topLeft = textOffset, + alpha = animMsgTextAlpha.value + ) } } ) @@ -657,6 +936,7 @@ fun ShowcaseLayout( TAG + "calc: ${offset.y + itemSize.height - (maxHeight.value / 2)}" ) } + } } @@ -676,7 +956,7 @@ class ShowcaseScopeImpl(greeting: ShowcaseMsg?) : ShowcaseScope { ) { require(index >= 1) { "Index must be 1 or greater" } Box(modifier = Modifier.onGloballyPositioned { - showcaseDataHashMap[index] = ShowcaseData(it.size, it.positionInRoot(), message) + showcaseDataHashMap[index] = ShowcaseData(it.size, it.positionInRoot(), message, it) showcaseEventListener?.onEvent(Level.VERBOSE, TAG + "Index: $index") showcaseEventListener?.onEvent( @@ -699,7 +979,8 @@ class ShowcaseScopeImpl(greeting: ShowcaseMsg?) : ShowcaseScope { return this.then( onGloballyPositioned { if (it.isAttached) { - showcaseDataHashMap[index] = ShowcaseData(it.size, it.positionInRoot(), message) + showcaseDataHashMap[index] = + ShowcaseData(it.size, it.positionInRoot(), message, it) showcaseEventListener?.onEvent(Level.VERBOSE, TAG + "Index: $index") showcaseEventListener?.onEvent( Level.VERBOSE, @@ -723,7 +1004,7 @@ class ShowcaseScopeImpl(greeting: ShowcaseMsg?) : ShowcaseScope { _showcaseActionFlow.emit(index) } - suspend fun showcaseItemFinished() { + suspend fun showcaseItemFinished() { showcaseEventListener?.onEvent(Level.DEBUG, TAG + "showcase item finished") _showcaseActionFlow.emit(null) } @@ -736,7 +1017,7 @@ class ShowcaseScopeImpl(greeting: ShowcaseMsg?) : ShowcaseScope { ) } - suspend fun showGreetingFinished() { + suspend fun showGreetingFinished() { _greetingActionFlow.emit(null) showcaseEventListener?.onEvent( Level.DEBUG, diff --git a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseScope.kt b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseScope.kt index f9b1a1c..4d11903 100644 --- a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseScope.kt +++ b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseScope.kt @@ -52,7 +52,5 @@ interface ShowcaseScope { fun registerEventListener(eventListener: ShowcaseEventListener) suspend fun showcaseItem(index: Int) -// suspend fun showcaseItemFinished() suspend fun showGreeting(message: ShowcaseMsg) -// suspend fun showGreetingFinished() } \ No newline at end of file diff --git a/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/TargetShowcaseLayout.kt b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/TargetShowcaseLayout.kt new file mode 100644 index 0000000..57efaf2 --- /dev/null +++ b/showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/TargetShowcaseLayout.kt @@ -0,0 +1,1144 @@ +package ly.com.tahaben.showcase_layout_compose.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathOperation +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import ly.com.tahaben.showcase_layout_compose.domain.Level +import ly.com.tahaben.showcase_layout_compose.model.MsgAnimation +import ly.com.tahaben.showcase_layout_compose.model.ShowcaseMsg +import ly.com.tahaben.showcase_layout_compose.model.TargetShape +import kotlin.math.max +import kotlin.math.min + + +/** + * Copyright 2025 Taha Ben Ashur (tahaak67) + * 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 + * + * http://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. + * + * Created by Taha Ben Ashur (https://github.com/tahaak67) on 20,June,2025 + */ + +private const val TAG = "TargetShowcaseLayout " +private const val INDEX_RESET_DELAY = 250L + +/** + * TargetShowcaseLayout + * + * @param isShowcasing to determine if showcase is starting or not. + * @param isDarkLayout if true the showcase view will be white instead of black. + * @param initIndex the initial value of counter, set this to 1 if you don't want a greeting screen before showcasing target. + * @param animationDuration total animation time taken when switching from current to next target in milliseconds. + * @param onFinish what happens when all items are showcased. + * @param greeting greeting message to be shown before showcasing the first composable, leave [initIndex] at 0 if you want to use this. + * @param lineThickness thickness of the arrow line in dp. + * @param targetShape the shape of the target highlight, can be either CIRCLE, RECTANGLE, or ROUNDED_RECTANGLE. + * @param cornerRadius the radius of the corners when targetShape is ROUNDED_RECTANGLE. + * @param animateToNextTarget if true, the target shape will animate smoothly from one target to the next when the index changes. + * If false, the shape will shrink at the current location, then expand at the new location. + **/ +@Composable +fun TargetShowcaseLayout( + isShowcasing: Boolean, + isDarkLayout: Boolean = false, + initIndex: Int = 0, + animationDuration: Int = 1000, + onFinish: () -> Unit, + greeting: ShowcaseMsg? = null, + lineThickness: Dp = 5.dp, + targetShape: TargetShape = TargetShape.ROUNDED_RECTANGLE, + cornerRadius: Dp = 8.dp, + animateToNextTarget: Boolean = true, + content: @Composable ShowcaseScope.() -> Unit +) { + var currentIndex by remember { + mutableIntStateOf(initIndex) + } + val currentContent by rememberUpdatedState(content) + val scope = ShowcaseScopeImpl(greeting) + scope.currentContent() + val localDensity = LocalDensity.current + var singleGreetingMsg by remember { mutableStateOf(null) } + val showcaseItem = scope.showcaseActionFlow.collectAsState() + val showCasingItem by remember { + derivedStateOf { + if (showcaseItem.value != null) { + scope.showcaseEventListener?.onEvent( + Level.DEBUG, + TAG + "showcase single item index: ${showcaseItem.value}" + ) + currentIndex = showcaseItem.value ?: initIndex + true + } else { + false + } + } + } + val singleGreeting = scope.greetingActionFlow.collectAsState() + val isSingleGreeting by remember { + derivedStateOf { + if (singleGreeting.value != null) { + scope.showcaseEventListener?.onEvent( + Level.DEBUG, + TAG + "showcase single greeting: ${singleGreeting.value?.text}" + ) + singleGreetingMsg = singleGreeting.value + currentIndex = 0 + true + } else { + false + } + } + } + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val coroutineScope = rememberCoroutineScope() + if (isShowcasing || showCasingItem || isSingleGreeting) { + val itemSize = scope.getSizeFor(currentIndex) + val offset = scope.getPositionFor(currentIndex) + val animatedWidth = remember { Animatable(itemSize.width) } + val animatedHeight = remember { Animatable(itemSize.height) } + + val animatedX = remember { Animatable(offset.x) } + val animatedY = remember { Animatable(offset.y) } + val maxDimension = + max(maxHeight.value, maxWidth.value) + + val outerAnimatable = remember { Animatable(0.0f) } + + val message = if (isSingleGreeting) singleGreetingMsg else scope.getMessageFor(currentIndex) + val textMeasurer = rememberTextMeasurer() + val messageTextAlpha = remember { Animatable(0f) } + val canvasColor = message?.msgBackground ?: Color.Black.copy(alpha = 0.9f) + val canvasColorAnimated by animateColorAsState( + canvasColor, + animationSpec = tween(durationMillis = animationDuration, easing = FastOutSlowInEasing) + ) + + // Animation for overall canvas alpha to make the circle completely disappear + val canvasAlpha = remember { Animatable(0f) } + + // Animation for the pulse radius + val pulseRadius = remember { Animatable(0f) } + // Animation for the pulse transparency + val pulseAlpha = remember { Animatable(0.6f) } + + LaunchedEffect(currentIndex) { + val pulseDuration = 1200 + while (true) { + pulseAlpha.snapTo(0.6f) + pulseRadius.snapTo(0f) + launch { + pulseAlpha.animateTo( + targetValue = 0.0f, + animationSpec = tween( + durationMillis = pulseDuration, + easing = FastOutSlowInEasing, + ) + ) + } + launch { + pulseRadius.animateTo( + targetValue = maxDimension, // Maximum additional radius of the pulse + animationSpec = infiniteRepeatable( + animation = tween( + pulseDuration, + easing = FastOutLinearInEasing + ), // 1-second animation + repeatMode = RepeatMode.Restart + ) + ) + } + delay(pulseDuration.toLong()) + } + } + + LaunchedEffect(currentIndex) { + + if (currentIndex == 0 || isSingleGreeting) { + //canvasAlpha.snapTo(0f) + canvasAlpha.animateTo( + 1f, + animationSpec = tween(durationMillis = animationDuration / 2, easing = FastOutSlowInEasing) + ) + } + // If this is the first showcase or we're resetting, snap to initial values + if (currentIndex == 0 || currentIndex == initIndex) { + delay(animationDuration.toLong()) + handleMessageEnterAnimation(message, messageTextAlpha, animationDuration) + } else { + if (animateToNextTarget && !showCasingItem) { + outerAnimatable.snapTo(1f) + // Reset canvas alpha for the new target + canvasAlpha.snapTo(1f) + + // Animate the position of the inner target highlight + launch { + animatedX.animateTo( + offset.x, + animationSpec = tween( + durationMillis = animationDuration, + easing = FastOutSlowInEasing + ) + ) + } + launch { + animatedY.animateTo( + offset.y, + animationSpec = tween( + durationMillis = animationDuration, + easing = FastOutSlowInEasing + ) + ) + } + + // Animate the size of the inner target highlight + // Use a single coroutine to ensure width and height animations are synchronized + launch { + // Animate both width and height together with the same duration and easing + // This ensures the shape remains circular during the animation + animatedWidth.animateTo( + itemSize.width, + animationSpec = tween( + durationMillis = animationDuration, + easing = FastOutSlowInEasing + ) + ) + + animatedHeight.animateTo( + itemSize.height, + animationSpec = tween( + durationMillis = animationDuration, + easing = FastOutSlowInEasing + ) + ) + } + + } else { + animatedX.snapTo(offset.x) + animatedY.snapTo(offset.y) + animatedHeight.snapTo(0f) + animatedWidth.snapTo(0f) + + launch { + animatedHeight.animateTo(itemSize.height) + } + launch { + animatedWidth.animateTo(itemSize.width) + } + + delay(animationDuration.toLong() / 3) + canvasAlpha.animateTo(1f) + + // Expand at new location + val expandDuration = animationDuration / 3 + + // Animate the outer circle back in + launch { + outerAnimatable.animateTo( + 1f, + animationSpec = tween( + durationMillis = expandDuration, + easing = FastOutSlowInEasing + ) + ) + } + + } + + // After the animation has completed, fade the message text back in + launch { + delay(animationDuration.toLong()) + handleMessageEnterAnimation(message, messageTextAlpha, animationDuration) + } + } + } + LaunchedEffect(isShowcasing) { + if (isShowcasing && currentIndex != 0 && !isSingleGreeting) { + pulseAlpha.snapTo(0.6f) + pulseRadius.snapTo(0f) + } + } + + Canvas( + modifier = Modifier.fillMaxSize() + .semantics { testTag = "circleModeCanvas" } + .pointerInput(Unit) { + detectTapGestures { + scope.showcaseEventListener?.onEvent( + Level.VERBOSE, + TAG + "tapped here $it" + ) + if (showCasingItem) { + val shrinkDuration = animationDuration + + // Shrink both width and height simultaneously and shrink the outer circle + coroutineScope.launch { + handleMessageExitAnimation(message, messageTextAlpha, animationDuration) + // Shrink the outer circle + launch { + outerAnimatable.animateTo( + 0f, + animationSpec = tween( + durationMillis = shrinkDuration, + easing = FastOutSlowInEasing + ) + ) + } + + // Fade out the entire canvas to make the circle completely disappear + launch { + canvasAlpha.animateTo( + 0f, + animationSpec = tween( + durationMillis = shrinkDuration, + easing = FastOutSlowInEasing + ) + ) + } + + // Wait for animations to complete + delay((animationDuration / 3).toLong()) + + // Finish showcasing the single item + scope.showcaseItemFinished() + currentIndex = initIndex + } + return@detectTapGestures + } else if (isSingleGreeting) { + coroutineScope.launch { + handleMessageExitAnimation(singleGreetingMsg, messageTextAlpha, animationDuration) + + // Fade out the entire canvas to make the circle completely disappear + launch { + canvasAlpha.animateTo( + 0f, + animationSpec = tween( + durationMillis = animationDuration / 3, + easing = FastOutSlowInEasing + ) + ) + } + + // Wait for animations to complete + delay((animationDuration / 3).toLong()) + + // Finish showcasing the greeting + scope.showGreetingFinished() + + currentIndex = initIndex + } + return@detectTapGestures + } else if (currentIndex + 1 < scope.getHashMapSize()) { + if (!animateToNextTarget) { + // Shrink at current location, then move to new location, then expand + // Step 1: Shrink at current location + val shrinkDuration = animationDuration + + // Shrink both width and height simultaneously and shrink the outer circle + coroutineScope.launch { + handleMessageExitAnimation(message, messageTextAlpha, animationDuration) + + // Shrink the outer circle + launch { + outerAnimatable.animateTo( + 0f, + animationSpec = tween( + durationMillis = shrinkDuration, + easing = FastOutSlowInEasing + ) + ) + } + + // Fade out the entire canvas to make the circle completely disappear + launch { + canvasAlpha.animateTo( + 0f, + animationSpec = tween( + durationMillis = shrinkDuration, + easing = FastOutSlowInEasing + ) + ) + } + + // Wait for animations to complete + delay(shrinkDuration.toLong()) + + // Move to the next target + currentIndex++ + + } + + } else { + // When animateToNextTarget is true, we don't want to fade out the outer circle + // we just handle message animation if any then move to the next target + coroutineScope.launch { + handleMessageExitAnimation(message, messageTextAlpha, animationDuration) + + // Move to the next target + currentIndex++ + } + } + + } else { + // This is the last target, finish the showcase + + coroutineScope.launch { + val shrinkDuration = animationDuration / 2 + handleMessageExitAnimation(singleGreetingMsg, messageTextAlpha, animationDuration) + + // Fade out the entire canvas to make the circle completely disappear + launch { + canvasAlpha.animateTo( + 0f, + animationSpec = tween( + durationMillis = shrinkDuration, + easing = FastOutSlowInEasing + ) + ) + } + + // Wait for animations to complete + delay(shrinkDuration.toLong()) + + // Reset index and call onFinish + currentIndex = initIndex + onFinish() + } + + } + } + } + ) { + if (isSingleGreeting || currentIndex == 0) { + // For greeting, fill the entire screen with a solid color + drawRect( + color = canvasColor.copy(alpha = 0.9f), + size = size, + alpha = canvasAlpha.value + ) + + // Display the greeting message in the middle of the screen + message?.let { msg -> + // Measure text with appropriate constraints to ensure it wraps if needed + val maxTextWidth = max(1, (size.width * 0.8f).toInt()) // Use 80% of screen width + val textResult = textMeasurer.measure( + msg.text, + style = msg.textStyle, + overflow = TextOverflow.Visible, + constraints = Constraints(0, maxTextWidth) + ) + + // Center the text on the screen + val textX = (size.width - textResult.size.width) / 2 + val textY = (size.height - textResult.size.height) / 2 + + // Draw a background for the text with padding + val bgPadding = 40f + val bgRect = Rect( + left = textX - bgPadding, + top = textY - bgPadding, + right = textX + textResult.size.width + bgPadding, + bottom = textY + textResult.size.height + bgPadding + ) + + // Draw the text background + if (msg.roundedCorner == 0.dp) { + drawRect( + color = msg.msgBackground ?: Color.Transparent, + topLeft = Offset(bgRect.left, bgRect.top), + size = Size(bgRect.width, bgRect.height), + alpha = messageTextAlpha.value + ) + } else { + drawRoundRect( + color = msg.msgBackground ?: Color.Transparent, + topLeft = Offset(bgRect.left, bgRect.top), + size = Size(bgRect.width, bgRect.height), + cornerRadius = CornerRadius(msg.roundedCorner.value), + alpha = messageTextAlpha.value + ) + } + + // Draw the text + drawText( + textResult, + topLeft = Offset(textX, textY), + alpha = messageTextAlpha.value + ) + } + + // Return early to avoid drawing the target shape + return@Canvas + } + // Calculate the radius for the target shape + // For a circle, this is the actual radius + // For a rectangle, we'll use the actual width and height + val circleRadius = max(itemSize.width, itemSize.height).div(2) + // Half width and height for rectangle mode + val rectHalfWidth = itemSize.width.div(2) + val rectHalfHeight = itemSize.height.div(2) + val itemCenter = Offset( + animatedX.value + itemSize.width.div(2), + animatedY.value + itemSize.height.div(2) + ) + + // Constants for padding and spacing + val safetyPadding = 45f + val horizontalSafetyPadding = safetyPadding * 1.5f + + + // Initial calculation of outer circle dimensions (will be adjusted based on text) + // Set the outer circle radius to half of the canvas width + val outerRadius = max(size.width, size.height) / 2 + val outerLeft = itemCenter.x - outerRadius + val outerTop = itemCenter.y - outerRadius + val outerRight = itemCenter.x + outerRadius + val outerBottom = itemCenter.y + outerRadius + + // Variables to store text measurement and position + var textX = 0f + var textWidth = 0 + var textHeight = 0 + var textResult: TextLayoutResult? = null + + // Calculate text dimensions and position first, then adjust outer circle if needed + message?.let { msg -> + // First, calculate an initial estimate of available width for text + // This is a conservative estimate that will be refined + val initialMaxWidth = + min(size.width.toInt() - 200, (outerRight - outerLeft - 4 * safetyPadding).toInt()) + val maxTextWidth = max(1, initialMaxWidth) + + // Measure text with appropriate constraints + textResult = textMeasurer.measure( + msg.text, + style = msg.textStyle, + overflow = TextOverflow.Visible, + constraints = Constraints(0, maxTextWidth) + ) + + // Store text dimensions + textWidth = textResult.size.width + textHeight = textResult.size.height + + + // Calculate horizontal position (centered by default) + textX = itemCenter.x - textWidth / 2 + + // Ensure text stays within screen bounds horizontally + if (textX < horizontalSafetyPadding) { + textX = horizontalSafetyPadding + scope.showcaseEventListener?.onEvent( + Level.INFO, + TAG + "Text would extend beyond left edge of screen. Moving text right." + ) + } + + if (textX + textWidth > size.width - horizontalSafetyPadding) { + scope.showcaseEventListener?.onEvent( + Level.INFO, + TAG + "Text would extend beyond right edge of screen. Moving text left." + ) + } + + } + + // Create the donut path after all adjustments to outer circle dimensions + // Calculate the center of the outer circle + val outerCenterX = itemCenter.x//(outerLeft + outerRight) / 2 + val outerCenterY = itemCenter.y//(outerTop + outerBottom) / 2 + + // Calculate the width and height of the outer circle + val outerWidth = outerRight - outerLeft + val outerHeight = outerBottom - outerTop + + // Apply the scale animation to the outer circle + val scaledOuterWidth = outerWidth * outerAnimatable.value + val scaledOuterHeight = outerHeight * outerAnimatable.value + + // Calculate the new bounds of the scaled outer circle + val scaledOuterLeft = outerCenterX - scaledOuterWidth / 2 + val scaledOuterTop = outerCenterY - scaledOuterHeight / 2 + val scaledOuterRight = outerCenterX + scaledOuterWidth / 2 + val scaledOuterBottom = outerCenterY + scaledOuterHeight / 2 + + val donutPath = Path().apply { + op( + Path().apply { + // Outer oval (outer circle) - potentially adjusted for text and scaled + addOval( + Rect( + left = scaledOuterLeft, + top = scaledOuterTop, + right = scaledOuterRight, + bottom = scaledOuterBottom + ) + ) + }, + Path().apply { + // Inner shape - always at the target + // Use either a circle, rectangle, or rounded rectangle based on the targetShape parameter + when (targetShape) { + TargetShape.CIRCLE -> { + // Draw a circle for the inner cutout + addOval( + Rect( + left = itemCenter.x - circleRadius, + top = itemCenter.y - circleRadius, + right = itemCenter.x + circleRadius, + bottom = itemCenter.y + circleRadius + ) + ) + } + + TargetShape.ROUNDED_RECTANGLE -> { + // Draw a rounded rectangle for the inner cutout + val rect = Rect( + left = itemCenter.x - rectHalfWidth, + top = itemCenter.y - rectHalfHeight, + right = itemCenter.x + rectHalfWidth, + bottom = itemCenter.y + rectHalfHeight + ) + RoundRect( + left = itemCenter.x - rectHalfWidth, + top = itemCenter.y - rectHalfHeight, + right = itemCenter.x + rectHalfWidth, + bottom = itemCenter.y + rectHalfHeight, + ) + + val cornerRadiusPx = with(localDensity) { + cornerRadius.toPx() + } + + // Top-left arc + moveTo(rect.left, rect.top + cornerRadiusPx) + arcTo( + rect = Rect( + left = rect.left, + top = rect.top, + right = rect.left + cornerRadiusPx * 2, + bottom = rect.top + cornerRadiusPx * 2 + ), + startAngleDegrees = 180f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Top-right arc + lineTo(rect.right - cornerRadiusPx, rect.top) + arcTo( + rect = Rect( + left = rect.right - cornerRadiusPx * 2, + top = rect.top, + right = rect.right, + bottom = rect.top + cornerRadiusPx * 2 + ), + startAngleDegrees = 270f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Bottom-right arc + lineTo(rect.right, rect.bottom - cornerRadiusPx) + arcTo( + rect = Rect( + left = rect.right - cornerRadiusPx * 2, + top = rect.bottom - cornerRadiusPx * 2, + right = rect.right, + bottom = rect.bottom + ), + startAngleDegrees = 0f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Bottom-left arc + lineTo(rect.left + cornerRadiusPx, rect.bottom) + arcTo( + rect = Rect( + left = rect.left, + top = rect.bottom - cornerRadiusPx * 2, + right = rect.left + cornerRadiusPx * 2, + bottom = rect.bottom + ), + startAngleDegrees = 90f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + close() + } + + else -> { + // Draw a rectangle for the inner cutout that matches the target's exact dimensions + addRect( + Rect( + left = itemCenter.x - rectHalfWidth, + top = itemCenter.y - rectHalfHeight, + right = itemCenter.x + rectHalfWidth, + bottom = itemCenter.y + rectHalfHeight + ) + ) + } + } + }, + operation = PathOperation.Difference + ) + } + + // Draw the donut path with the dimensions that have been adjusted for text + // Apply canvasAlpha to make the circle completely disappear during transitions + drawPath( + path = donutPath, + color = canvasColorAnimated.copy(alpha = 0.9f * canvasAlpha.value), + style = Fill // Fill the donut shape + ) + + // Draw the pulsing ring (outside the punch) + val pulsePath = Path().apply { + op( + Path().apply { + // Use either a circle, rectangle, or rounded rectangle for the outer pulse based on the targetShape parameter + // This ensures that the pulse animation matches the shape of the target + when (targetShape) { + TargetShape.CIRCLE -> { + // Draw a circle for the outer pulse + addOval( + Rect( + left = itemCenter.x - (circleRadius + pulseRadius.value), + top = itemCenter.y - (circleRadius + pulseRadius.value), + right = itemCenter.x + (circleRadius + pulseRadius.value), + bottom = itemCenter.y + (circleRadius + pulseRadius.value) + ) + ) + } + + TargetShape.ROUNDED_RECTANGLE -> { + // Draw a rounded rectangle for the outer pulse + val rect = Rect( + left = itemCenter.x - (rectHalfWidth + pulseRadius.value), + top = itemCenter.y - (rectHalfHeight + pulseRadius.value), + right = itemCenter.x + (rectHalfWidth + pulseRadius.value), + bottom = itemCenter.y + (rectHalfHeight + pulseRadius.value) + ) + val cornerRadiusPx = cornerRadius.toPx() + + // Top-left arc + moveTo(rect.left, rect.top + cornerRadiusPx) + arcTo( + rect = Rect( + left = rect.left, + top = rect.top, + right = rect.left + cornerRadiusPx * 2, + bottom = rect.top + cornerRadiusPx * 2 + ), + startAngleDegrees = 180f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Top-right arc + lineTo(rect.right - cornerRadiusPx, rect.top) + arcTo( + rect = Rect( + left = rect.right - cornerRadiusPx * 2, + top = rect.top, + right = rect.right, + bottom = rect.top + cornerRadiusPx * 2 + ), + startAngleDegrees = 270f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Bottom-right arc + lineTo(rect.right, rect.bottom - cornerRadiusPx) + arcTo( + rect = Rect( + left = rect.right - cornerRadiusPx * 2, + top = rect.bottom - cornerRadiusPx * 2, + right = rect.right, + bottom = rect.bottom + ), + startAngleDegrees = 0f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Bottom-left arc + lineTo(rect.left + cornerRadiusPx, rect.bottom) + arcTo( + rect = Rect( + left = rect.left, + top = rect.bottom - cornerRadiusPx * 2, + right = rect.left + cornerRadiusPx * 2, + bottom = rect.bottom + ), + startAngleDegrees = 90f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + close() + } + + else -> { + // Draw a rectangle for the outer pulse that matches the target's exact dimensions + addRect( + Rect( + left = itemCenter.x - (rectHalfWidth + pulseRadius.value), + top = itemCenter.y - (rectHalfHeight + pulseRadius.value), + right = itemCenter.x + (rectHalfWidth + pulseRadius.value), + bottom = itemCenter.y + (rectHalfHeight + pulseRadius.value) + ) + ) + } + } + }, + Path().apply { + // Use either a circle, rectangle, or rounded rectangle for the inner pulse based on the targetShape parameter + // This ensures that the inner cutout of the pulse animation matches the shape of the target + when (targetShape) { + TargetShape.CIRCLE -> { + // Draw a circle for the inner pulse + addOval( + Rect( + left = itemCenter.x - circleRadius, + top = itemCenter.y - circleRadius, + right = itemCenter.x + circleRadius, + bottom = itemCenter.y + circleRadius + ) + ) + } + + TargetShape.ROUNDED_RECTANGLE -> { + // Draw a rounded rectangle for the inner pulse + val rect = Rect( + left = itemCenter.x - rectHalfWidth, + top = itemCenter.y - rectHalfHeight, + right = itemCenter.x + rectHalfWidth, + bottom = itemCenter.y + rectHalfHeight + ) + val cornerRadiusPx = cornerRadius.toPx() + + // Top-left arc + moveTo(rect.left, rect.top + cornerRadiusPx) + arcTo( + rect = Rect( + left = rect.left, + top = rect.top, + right = rect.left + cornerRadiusPx * 2, + bottom = rect.top + cornerRadiusPx * 2 + ), + startAngleDegrees = 180f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Top-right arc + lineTo(rect.right - cornerRadiusPx, rect.top) + arcTo( + rect = Rect( + left = rect.right - cornerRadiusPx * 2, + top = rect.top, + right = rect.right, + bottom = rect.top + cornerRadiusPx * 2 + ), + startAngleDegrees = 270f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Bottom-right arc + lineTo(rect.right, rect.bottom - cornerRadiusPx) + arcTo( + rect = Rect( + left = rect.right - cornerRadiusPx * 2, + top = rect.bottom - cornerRadiusPx * 2, + right = rect.right, + bottom = rect.bottom + ), + startAngleDegrees = 0f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + // Bottom-left arc + lineTo(rect.left + cornerRadiusPx, rect.bottom) + arcTo( + rect = Rect( + left = rect.left, + top = rect.bottom - cornerRadiusPx * 2, + right = rect.left + cornerRadiusPx * 2, + bottom = rect.bottom + ), + startAngleDegrees = 90f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + close() + } + + else -> { + // Draw a rectangle for the inner pulse that matches the target's exact dimensions + addRect( + Rect( + left = itemCenter.x - rectHalfWidth, + top = itemCenter.y - rectHalfHeight, + right = itemCenter.x + rectHalfWidth, + bottom = itemCenter.y + rectHalfHeight + ) + ) + } + } + }, + operation = PathOperation.Difference + ) + } + + // Apply canvasAlpha to make the pulse animation also disappear during transitions + drawPath( + path = pulsePath, + color = Color.White.copy(alpha = pulseAlpha.value * canvasAlpha.value), + style = Fill + ) + + // Draw the message text between the inner and outer circle bounds + message?.let { msg -> + // Constants for padding and spacing - use the same values as in the outer circle adjustment + // to ensure consistency + val safetyPadding = 100f + val textPadding = 24f + + // Calculate the maximum width available for text + // Use a narrower width to ensure text wraps appropriately and stays within bounds + // Use a more conservative width to ensure text doesn't extend beyond outer circle + val calculatedWidth = + min(size.width.toInt() - 200, (outerRight - outerLeft - 4 * safetyPadding).toInt()) + // Ensure maxTextWidth is always positive to avoid Constraints exception + val maxTextWidth = max(1, calculatedWidth) + + // Measure text with appropriate constraints to ensure it wraps if needed + val textResult = textMeasurer.measure( + msg.text, + style = msg.textStyle, + overflow = TextOverflow.Visible, + constraints = Constraints(0, maxTextWidth) + ) + + // Check if displaying the text above the target would make it go outside the screen bounds + // Use increased safety padding to ensure more space between text and outer circle + // Use rectHalfHeight for rectangle mode to position text correctly relative to the rectangle + val verticalOffset = if (targetShape == TargetShape.CIRCLE) circleRadius else rectHalfHeight + val textAboveY = itemCenter.y - verticalOffset - textResult.size.height - safetyPadding * 1.5f + val textBelowY = itemCenter.y + verticalOffset + safetyPadding * 1.5f + val isTextAbove = textAboveY >= safetyPadding + + // If text would be outside the screen bounds when displayed above, show it below + val textY = if (!isTextAbove) { + scope.showcaseEventListener?.onEvent( + Level.INFO, + TAG + "Text would be outside screen bounds if displayed above. Displaying below target instead." + ) + // Make sure text stays within the adjusted outer circle when displayed below + min(textBelowY, outerBottom - textResult.size.height - safetyPadding * 1.5f) + } else { + // Make sure text stays within the adjusted outer circle when displayed above + max(textAboveY, outerTop + safetyPadding * 1.5f) + } + + // Log if text is very close to the outer circle bounds + if (isTextAbove && textY < outerTop + safetyPadding * 2) { + scope.showcaseEventListener?.onEvent( + Level.INFO, + TAG + "Text is very close to the top edge of the outer circle." + ) + } else if (!isTextAbove && textY + textResult.size.height > outerBottom - safetyPadding * 2) { + scope.showcaseEventListener?.onEvent( + Level.INFO, + TAG + "Text is very close to the bottom edge of the outer circle." + ) + } + + // Ensure text doesn't extend beyond the left or right edges of the screen and outer circle + var textX = itemCenter.x - textResult.size.width / 2 + + // Use increased safety padding for all edge checks + val horizontalSafetyPadding = safetyPadding * 1.5f + + // Adjust if text would go beyond left edge of screen + if (textX < horizontalSafetyPadding) { + scope.showcaseEventListener?.onEvent( + Level.INFO, + TAG + "Text would extend beyond left edge of screen. Adjusting position." + ) + textX = horizontalSafetyPadding + } + + // Adjust if text would go beyond right edge of screen + if (textX + textResult.size.width > size.width - horizontalSafetyPadding) { + scope.showcaseEventListener?.onEvent( + Level.INFO, + TAG + "Text would extend beyond right edge of screen. Adjusting position." + ) + textX = size.width - textResult.size.width - horizontalSafetyPadding + } + + // Also ensure text stays within the horizontal bounds of the outer circle + // with increased safety padding + if (textX < outerLeft + horizontalSafetyPadding) { + scope.showcaseEventListener?.onEvent( + Level.INFO, + TAG + "Text would extend beyond left edge of outer circle. Adjusting position." + ) + textX = outerLeft + horizontalSafetyPadding + } + + if (textX + textResult.size.width > outerRight - horizontalSafetyPadding) { + scope.showcaseEventListener?.onEvent( + Level.INFO, + TAG + "Text would extend beyond right edge of outer circle. Adjusting position." + ) + textX = outerRight - textResult.size.width - horizontalSafetyPadding + } + + // If after all adjustments, text still doesn't fit within screen bounds, + // prioritize keeping it on screen over keeping it within outer circle + if (textX < horizontalSafetyPadding) { + textX = horizontalSafetyPadding + scope.showcaseEventListener?.onEvent( + Level.INFO, + TAG + "Text still extends beyond left edge of screen. Forcing on-screen position." + ) + } + + if (textX + textResult.size.width > size.width - horizontalSafetyPadding) { + textX = size.width - textResult.size.width - horizontalSafetyPadding + scope.showcaseEventListener?.onEvent( + Level.INFO, + TAG + "Text still extends beyond right edge of screen. Forcing on-screen position." + ) + } + + // Draw a background for the text with increased padding + // Use a larger padding for the background to ensure text has room to breathe + val bgPadding = textPadding * 1.5f + val bgRect = Rect( + left = textX - bgPadding, + top = textY - bgPadding, + right = textX + textResult.size.width + bgPadding, + bottom = textY + textResult.size.height + bgPadding + ) + + // Add an additional safety margin for the check + val extraSafetyMargin = safetyPadding * 0.75f + + // Log if text is very close to the outer circle bounds + if (bgRect.left < outerLeft + safetyPadding + extraSafetyMargin || + bgRect.right > outerRight - safetyPadding - extraSafetyMargin || + bgRect.top < outerTop + safetyPadding + extraSafetyMargin || + bgRect.bottom > outerBottom - safetyPadding - extraSafetyMargin + ) { + scope.showcaseEventListener?.onEvent( + Level.INFO, + TAG + "Text is very close to the edge of the outer circle." + ) + } + + // Draw the text with the animated alpha + drawText( + textResult, + topLeft = Offset(textX, textY), + alpha = messageTextAlpha.value * canvasAlpha.value + ) + } + } + } + } +} + +suspend fun handleMessageExitAnimation( + message: ShowcaseMsg?, + messageTextAlpha: Animatable, + animationDuration: Int +) { + when (message?.exitAnim) { + is MsgAnimation.FadeInOut -> { + // Fade out the message text + messageTextAlpha.animateTo( + 0f, + animationSpec = tween( + durationMillis = animationDuration / 3, + easing = FastOutSlowInEasing + ) + ) + } + + MsgAnimation.None -> { + // Make the message text disappear + messageTextAlpha.snapTo(0f) + } + + null -> Unit + } +} + +suspend fun handleMessageEnterAnimation( + message: ShowcaseMsg?, + messageTextAlpha: Animatable, + animationDuration: Int +) { + when (message?.enterAnim) { + is MsgAnimation.FadeInOut -> { + // Fade out the message text + messageTextAlpha.animateTo( + 1f, + animationSpec = tween( + durationMillis = animationDuration / 3, + easing = FastOutSlowInEasing + ) + ) + } + + MsgAnimation.None -> { + // Make the message text disappear + messageTextAlpha.snapTo(1f) + } + + null -> Unit + } +}