├── .gitattributes ├── .github └── workflows │ ├── build_workflow.main.kts │ ├── build_workflow.yaml │ ├── fix_workflow.main.kts │ └── fix_workflow.yaml ├── .gitignore ├── .idea ├── .gitignore ├── .name └── runConfigurations │ └── app_watch__installDebug_.xml ├── .screenshots └── kotlin-and-compose-fan-clock.png ├── LICENSE ├── README.md ├── app-phone ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── louiscad │ │ │ └── composeoclockplayground │ │ │ ├── MainActivity.kt │ │ │ └── ui │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── louiscad │ └── composeoclockplayground │ └── ExampleUnitTest.kt ├── app-watch ├── .gitignore ├── build.gradle.kts ├── lint.xml ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── kotlin │ ├── MainActivity.kt │ ├── SampleWatchFaceService.kt │ ├── WatchFaceConfigActivity.kt │ ├── editor │ │ ├── WatchFaceConfigContent.kt │ │ └── WatchFaceEditorSession.kt │ └── presentation │ │ └── theme │ │ └── Theme.kt │ └── res │ ├── drawable │ ├── splash_icon.xml │ └── watch_preview.png │ ├── mipmap-hdpi │ └── ic_launcher.webp │ ├── mipmap-mdpi │ └── ic_launcher.webp │ ├── mipmap-xhdpi │ └── ic_launcher.webp │ ├── mipmap-xxhdpi │ └── ic_launcher.webp │ ├── mipmap-xxxhdpi │ └── ic_launcher.webp │ ├── values-round │ └── strings.xml │ ├── values │ ├── strings.xml │ └── styles.xml │ └── xml │ └── watch_face.xml ├── build.gradle.kts ├── convention-plugins ├── .gitignore ├── build.gradle.kts ├── gradle.properties ├── settings.gradle.kts └── src │ └── main │ └── kotlin │ ├── Versioning.kt │ ├── android-app.gradle.kts │ ├── android-crashlytics.gradle.kts │ ├── android-lib.gradle.kts │ ├── android-signing-config.gradle.kts │ ├── version-code-phone.gradle.kts │ └── version-code-watch.gradle.kts ├── debug.keystore ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── releaseBuildNumber.txt ├── settings.gradle.kts ├── shared ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── louiscad │ │ └── shared │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── kotlin │ │ ├── ComposeOClockWatermark.kt │ │ ├── ForPaper.kt │ │ ├── GoogleFonts.kt │ │ ├── WatchFacePreview.kt │ │ ├── cleanthisbeforerelease │ │ │ ├── complications │ │ │ │ └── PixelWatchStyleComplication.kt │ │ │ ├── elements │ │ │ │ ├── CirclePatterns.kt │ │ │ │ ├── LinePatterns.kt │ │ │ │ ├── PatternHelpers.kt │ │ │ │ └── PatternsPreviews.kt │ │ │ └── experiments │ │ │ │ ├── CirclesExperiment.kt │ │ │ │ ├── KotlinLogoExperiments.kt │ │ │ │ ├── ShadersExperiments.kt │ │ │ │ └── TextOnPathExperiments.kt │ │ ├── elements │ │ │ ├── ClockHand.kt │ │ │ ├── FatHourDigits.kt │ │ │ ├── HourDigitsHelpers.kt │ │ │ ├── LoopingSeconds.kt │ │ │ ├── MyPath.kt │ │ │ ├── SinusoidalCrown.kt │ │ │ └── vectors │ │ │ │ ├── composeMultiplatformLogo.kt │ │ │ │ └── wearOsLogo.kt │ │ ├── extensions │ │ │ ├── AndroidTextPaint.kt │ │ │ ├── Colors.kt │ │ │ ├── DrawScope.kt │ │ │ ├── DrawTransform.kt │ │ │ ├── GraphicsLayer.kt │ │ │ ├── Math.kt │ │ │ ├── Offset.kt │ │ │ ├── Paint.kt │ │ │ ├── Path.kt │ │ │ ├── PathPattern.kt │ │ │ ├── PatternsMath.kt │ │ │ ├── Size.kt │ │ │ ├── SizeDependentState.kt │ │ │ ├── TextLayoutResult.kt │ │ │ └── text │ │ │ │ ├── TextOnPathLayoutResult.kt │ │ │ │ ├── TextOnPathMeasurer.kt │ │ │ │ └── TextOnPathMeasurerHelper.kt │ │ ├── utils │ │ │ ├── Bits.kt │ │ │ ├── FiveMinutesLayoutOrder.kt │ │ │ ├── FiveMinutesSlicePattern.kt │ │ │ └── PieSlicePath.kt │ │ └── watchfaces │ │ │ ├── AllWatchFaces.kt │ │ │ ├── BasicAnalogClock.kt │ │ │ ├── ComposeFanClock.kt │ │ │ ├── KotlinFanClock.kt │ │ │ ├── LightLinesWatchFace.kt │ │ │ ├── WatchFaceSwitcher.kt │ │ │ └── hansie │ │ │ └── HansieClock.kt │ └── res │ │ ├── drawable │ │ └── jetpack_compose.xml │ │ ├── font │ │ └── outfit_extrabold.ttf │ │ └── values │ │ └── font_certs.xml │ └── test │ ├── kotlin │ └── org │ │ └── splitties │ │ └── compose │ │ └── oclock │ │ └── sample │ │ └── watchfaces │ │ ├── BasicAnalogClockTest.kt │ │ ├── ClockScreenshotTest.kt │ │ ├── ComposeFanClockTest.kt │ │ ├── DeviceClockScreenshotTest.kt │ │ ├── Devices.kt │ │ └── KotlinFanClockTest.kt │ └── screenshots │ ├── BasicAnalogClockTest_galaxy_watch_5.png │ ├── BasicAnalogClockTest_galaxy_watch_5_ambient.png │ ├── BasicAnalogClockTest_pixelwatch.png │ ├── BasicAnalogClockTest_pixelwatch_ambient.png │ ├── BasicAnalogClockTest_ticwatch_pro_5.png │ ├── BasicAnalogClockTest_ticwatch_pro_5_ambient.png │ ├── ComposeFanClockTest_galaxy_watch_5.png │ ├── ComposeFanClockTest_galaxy_watch_5_ambient.png │ ├── ComposeFanClockTest_pixelwatch.png │ ├── ComposeFanClockTest_pixelwatch_ambient.png │ ├── ComposeFanClockTest_ticwatch_pro_5.png │ ├── ComposeFanClockTest_ticwatch_pro_5_ambient.png │ ├── KotlinFanClockTest_galaxy_watch_5.png │ ├── KotlinFanClockTest_galaxy_watch_5_ambient.png │ ├── KotlinFanClockTest_pixelwatch.png │ ├── KotlinFanClockTest_pixelwatch_ambient.png │ ├── KotlinFanClockTest_ticwatch_pro_5.png │ └── KotlinFanClockTest_ticwatch_pro_5_ambient.png ├── version.txt └── versions.properties /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | # 4 | # These are explicitly windows files and should use crlf 5 | *.bat text eol=crlf 6 | 7 | -------------------------------------------------------------------------------- /.github/workflows/build_workflow.main.kts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env kotlin 2 | 3 | @file:DependsOn("io.github.typesafegithub:github-workflows-kt:1.11.0") 4 | 5 | import io.github.typesafegithub.workflows.actions.actions.CheckoutV4 6 | import io.github.typesafegithub.workflows.actions.actions.SetupJavaV4 7 | import io.github.typesafegithub.workflows.actions.actions.UploadArtifactV4 8 | import io.github.typesafegithub.workflows.actions.gradle.GradleBuildActionV3 9 | import io.github.typesafegithub.workflows.domain.RunnerType.UbuntuLatest 10 | import io.github.typesafegithub.workflows.domain.triggers.PullRequest 11 | import io.github.typesafegithub.workflows.domain.triggers.Push 12 | import io.github.typesafegithub.workflows.dsl.expressions.expr 13 | import io.github.typesafegithub.workflows.dsl.workflow 14 | import io.github.typesafegithub.workflows.yaml.writeToFile 15 | 16 | workflow( 17 | name = "Build workflow", 18 | on = listOf( 19 | Push(branches = listOf("main")), 20 | PullRequest(), 21 | ), 22 | sourceFile = __FILE__.toPath(), 23 | ) { 24 | job( 25 | id = "build-and-test", 26 | runsOn = UbuntuLatest 27 | ) { 28 | uses(name = "Check out", action = CheckoutV4()) 29 | uses( 30 | name = "Setup Java", 31 | action = SetupJavaV4( 32 | distribution = SetupJavaV4.Distribution.Temurin, 33 | javaVersion = "17" 34 | ) 35 | ) 36 | uses( 37 | name = "Build", 38 | action = GradleBuildActionV3( 39 | arguments = "test", 40 | ) 41 | ) 42 | uses( 43 | name = "Upload reports", 44 | action = UploadArtifactV4( 45 | name = "Roborazzi", 46 | path = listOf("shared/build/outputs/roborazzi"), 47 | retentionDays = UploadArtifactV4.RetentionPeriod.Default, 48 | ), 49 | `if` = expr { always() } 50 | ) 51 | uses( 52 | name = "Upload test reports", 53 | action = UploadArtifactV4( 54 | name = "Junit", 55 | path = listOf("**/build/reports/tests"), 56 | retentionDays = UploadArtifactV4.RetentionPeriod.Default, 57 | ), 58 | `if` = expr { always() } 59 | ) 60 | } 61 | }.writeToFile() 62 | -------------------------------------------------------------------------------- /.github/workflows/build_workflow.yaml: -------------------------------------------------------------------------------- 1 | # This file was generated using Kotlin DSL (.github/workflows/build_workflow.main.kts). 2 | # If you want to modify the workflow, please change the Kotlin file and regenerate this YAML file. 3 | # Generated with https://github.com/typesafegithub/github-workflows-kt 4 | 5 | name: 'Build workflow' 6 | on: 7 | push: 8 | branches: 9 | - 'main' 10 | pull_request: {} 11 | jobs: 12 | check_yaml_consistency: 13 | name: 'Check YAML consistency' 14 | runs-on: 'ubuntu-latest' 15 | steps: 16 | - id: 'step-0' 17 | name: 'Check out' 18 | uses: 'actions/checkout@v4' 19 | - id: 'step-1' 20 | name: 'Execute script' 21 | run: 'rm ''.github/workflows/build_workflow.yaml'' && ''.github/workflows/build_workflow.main.kts''' 22 | - id: 'step-2' 23 | name: 'Consistency check' 24 | run: 'git diff --exit-code ''.github/workflows/build_workflow.yaml''' 25 | build-and-test: 26 | runs-on: 'ubuntu-latest' 27 | needs: 28 | - 'check_yaml_consistency' 29 | steps: 30 | - id: 'step-0' 31 | name: 'Check out' 32 | uses: 'actions/checkout@v4' 33 | - id: 'step-1' 34 | name: 'Setup Java' 35 | uses: 'actions/setup-java@v4' 36 | with: 37 | java-version: '17' 38 | distribution: 'temurin' 39 | - id: 'step-2' 40 | name: 'Build' 41 | uses: 'gradle/gradle-build-action@v3' 42 | with: 43 | arguments: 'test' 44 | - id: 'step-3' 45 | name: 'Upload reports' 46 | uses: 'actions/upload-artifact@v4' 47 | with: 48 | name: 'Roborazzi' 49 | path: 'shared/build/outputs/roborazzi' 50 | retention-days: '0' 51 | if: '${{ always() }}' 52 | - id: 'step-4' 53 | name: 'Upload test reports' 54 | uses: 'actions/upload-artifact@v4' 55 | with: 56 | name: 'Junit' 57 | path: '**/build/reports/tests' 58 | retention-days: '0' 59 | if: '${{ always() }}' 60 | -------------------------------------------------------------------------------- /.github/workflows/fix_workflow.main.kts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env kotlin 2 | 3 | @file:DependsOn("io.github.typesafegithub:github-workflows-kt:1.11.0") 4 | 5 | import io.github.typesafegithub.workflows.actions.actions.CheckoutV4 6 | import io.github.typesafegithub.workflows.actions.actions.SetupJavaV4 7 | import io.github.typesafegithub.workflows.actions.gradle.GradleBuildActionV3 8 | import io.github.typesafegithub.workflows.actions.stefanzweifel.GitAutoCommitActionV5 9 | import io.github.typesafegithub.workflows.domain.RunnerType.UbuntuLatest 10 | import io.github.typesafegithub.workflows.domain.triggers.WorkflowDispatch 11 | import io.github.typesafegithub.workflows.dsl.expressions.expr 12 | import io.github.typesafegithub.workflows.dsl.workflow 13 | import io.github.typesafegithub.workflows.yaml.writeToFile 14 | 15 | workflow( 16 | name = "Fix workflow", 17 | on = listOf( 18 | WorkflowDispatch(), 19 | ), 20 | sourceFile = __FILE__.toPath(), 21 | ) { 22 | job( 23 | id = "fix-branch", 24 | runsOn = UbuntuLatest, 25 | // Ensure no auto-commits are ever made on the main/default branch. 26 | // This is an extra security measure, especially since we don't control what the 27 | // "stefanzweifel/git-auto-commit-action@v5" GitHub Action could theoretically do. 28 | `if` = expr { github.ref_name + " != " + github.eventWorkflowDispatch.repository.default_branch } 29 | ) { 30 | uses(name = "Check out", action = CheckoutV4()) 31 | uses( 32 | name = "Setup Java", 33 | action = SetupJavaV4(distribution = SetupJavaV4.Distribution.Temurin, javaVersion = "17") 34 | ) 35 | uses( 36 | name = "Record Screenshots", 37 | action = GradleBuildActionV3( 38 | arguments = "verifyAndRecordRoborazziDebug", 39 | ) 40 | ) 41 | uses( 42 | name = "Commit Screenshots", 43 | action = GitAutoCommitActionV5( 44 | filePattern = "**/src/test/screenshots/*.png", 45 | disableGlobbing = true, 46 | commitMessage = "🤖 Updates screenshots" 47 | ) 48 | ) 49 | } 50 | }.writeToFile() 51 | -------------------------------------------------------------------------------- /.github/workflows/fix_workflow.yaml: -------------------------------------------------------------------------------- 1 | # This file was generated using Kotlin DSL (.github/workflows/fix_workflow.main.kts). 2 | # If you want to modify the workflow, please change the Kotlin file and regenerate this YAML file. 3 | # Generated with https://github.com/typesafegithub/github-workflows-kt 4 | 5 | name: 'Fix workflow' 6 | on: 7 | workflow_dispatch: {} 8 | jobs: 9 | check_yaml_consistency: 10 | name: 'Check YAML consistency' 11 | runs-on: 'ubuntu-latest' 12 | steps: 13 | - id: 'step-0' 14 | name: 'Check out' 15 | uses: 'actions/checkout@v4' 16 | - id: 'step-1' 17 | name: 'Execute script' 18 | run: 'rm ''.github/workflows/fix_workflow.yaml'' && ''.github/workflows/fix_workflow.main.kts''' 19 | - id: 'step-2' 20 | name: 'Consistency check' 21 | run: 'git diff --exit-code ''.github/workflows/fix_workflow.yaml''' 22 | fix-branch: 23 | runs-on: 'ubuntu-latest' 24 | needs: 25 | - 'check_yaml_consistency' 26 | if: '${{ github.ref_name != github.event.repository.default_branch }}' 27 | steps: 28 | - id: 'step-0' 29 | name: 'Check out' 30 | uses: 'actions/checkout@v4' 31 | - id: 'step-1' 32 | name: 'Setup Java' 33 | uses: 'actions/setup-java@v4' 34 | with: 35 | java-version: '17' 36 | distribution: 'temurin' 37 | - id: 'step-2' 38 | name: 'Record Screenshots' 39 | uses: 'gradle/gradle-build-action@v3' 40 | with: 41 | arguments: 'verifyAndRecordRoborazziDebug' 42 | - id: 'step-3' 43 | name: 'Commit Screenshots' 44 | uses: 'stefanzweifel/git-auto-commit-action@v5' 45 | with: 46 | commit_message: '🤖 Updates screenshots' 47 | file_pattern: '**/src/test/screenshots/*.png' 48 | disable_globbing: 'true' 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | /compiler.xml 5 | /deploymentTargetDropDown.xml 6 | /gradle.xml 7 | /kotlinc.xml 8 | /migrations.xml 9 | /misc.xml 10 | /vcs.xml 11 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | ComposeOClock sample -------------------------------------------------------------------------------- /.idea/runConfigurations/app_watch__installDebug_.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | false 19 | true 20 | false 21 | false 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.screenshots/kotlin-and-compose-fan-clock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/.screenshots/kotlin-and-compose-fan-clock.png -------------------------------------------------------------------------------- /app-phone/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app-phone/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("android-app") 3 | id("version-code-phone") 4 | } 5 | 6 | android { 7 | namespace = "com.louiscad.composeoclockplayground" 8 | 9 | defaultConfig { 10 | applicationId = "com.louiscad.composeoclockplayground" 11 | minSdk = 26 12 | targetSdk = 34 13 | versionName = version.toString() 14 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 15 | } 16 | compileOptions { 17 | isCoreLibraryDesugaringEnabled = true 18 | } 19 | } 20 | 21 | dependencies { 22 | implementation { 23 | project(":shared")() 24 | 25 | AndroidX.compose.material3() 26 | AndroidX.compose.ui.toolingPreview() 27 | AndroidX.activity.compose() 28 | AndroidX.compose.ui() 29 | AndroidX.compose.ui.graphics() 30 | AndroidX.compose.ui.toolingPreview() 31 | AndroidX.compose.material3() 32 | } 33 | coreLibraryDesugaring(Android.tools.desugarJdkLibs) 34 | testImplementation { 35 | Testing.junit4() 36 | } 37 | androidTestImplementation { 38 | AndroidX.test.ext.junit() 39 | AndroidX.test.espresso.core() 40 | AndroidX.compose.ui.testJunit4() 41 | } 42 | debugImplementation { 43 | AndroidX.compose.ui.tooling() 44 | AndroidX.compose.ui.testManifest() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app-phone/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app-phone/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 15 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app-phone/src/main/java/com/louiscad/composeoclockplayground/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.louiscad.composeoclockplayground 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material3.Scaffold 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.tooling.preview.Preview 15 | import com.louiscad.composeoclockplayground.ui.theme.MyComposeOClockPlaygroundTheme 16 | import org.splitties.compose.oclock.OClockRootCanvas 17 | import org.splitties.compose.oclock.sample.watchfaces.WatchFaceSwitcher 18 | 19 | class MainActivity : ComponentActivity() { 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | super.onCreate(savedInstanceState) 22 | enableEdgeToEdge() 23 | setContent { 24 | MyComposeOClockPlaygroundTheme { 25 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> 26 | Content(Modifier.padding(innerPadding)) 27 | } 28 | } 29 | } 30 | } 31 | } 32 | 33 | @Composable 34 | fun Content(modifier: Modifier = Modifier) { 35 | Box( 36 | modifier = modifier, 37 | contentAlignment = Alignment.Center 38 | ) { 39 | OClockRootCanvas { 40 | WatchFaceSwitcher() 41 | } 42 | } 43 | } 44 | 45 | @Preview(showBackground = true) 46 | @Composable 47 | fun ContentPreview() { 48 | MyComposeOClockPlaygroundTheme { 49 | Content() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app-phone/src/main/java/com/louiscad/composeoclockplayground/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.louiscad.composeoclockplayground.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app-phone/src/main/java/com/louiscad/composeoclockplayground/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.louiscad.composeoclockplayground.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.platform.LocalContext 13 | 14 | private val DarkColorScheme = darkColorScheme( 15 | primary = Purple80, 16 | secondary = PurpleGrey80, 17 | tertiary = Pink80 18 | ) 19 | 20 | private val LightColorScheme = lightColorScheme( 21 | primary = Purple40, 22 | secondary = PurpleGrey40, 23 | tertiary = Pink40 24 | 25 | /* Other default colors to override 26 | background = Color(0xFFFFFBFE), 27 | surface = Color(0xFFFFFBFE), 28 | onPrimary = Color.White, 29 | onSecondary = Color.White, 30 | onTertiary = Color.White, 31 | onBackground = Color(0xFF1C1B1F), 32 | onSurface = Color(0xFF1C1B1F), 33 | */ 34 | ) 35 | 36 | @Composable 37 | fun MyComposeOClockPlaygroundTheme( 38 | darkTheme: Boolean = isSystemInDarkTheme(), 39 | // Dynamic color is available on Android 12+ 40 | dynamicColor: Boolean = true, 41 | content: @Composable () -> Unit 42 | ) { 43 | val colorScheme = when { 44 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 45 | val context = LocalContext.current 46 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 47 | } 48 | 49 | darkTheme -> DarkColorScheme 50 | else -> LightColorScheme 51 | } 52 | 53 | MaterialTheme( 54 | colorScheme = colorScheme, 55 | typography = Typography, 56 | content = content 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /app-phone/src/main/java/com/louiscad/composeoclockplayground/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.louiscad.composeoclockplayground.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app-phone/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app-phone/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app-phone/src/main/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app-phone/src/main/res/mipmap-anydpi/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app-phone/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/app-phone/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app-phone/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/app-phone/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app-phone/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/app-phone/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app-phone/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/app-phone/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app-phone/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/app-phone/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app-phone/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/app-phone/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app-phone/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/app-phone/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app-phone/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/app-phone/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app-phone/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/app-phone/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app-phone/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/app-phone/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app-phone/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | 11 | -------------------------------------------------------------------------------- /app-phone/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | My ComposeOClock Playground 3 | -------------------------------------------------------------------------------- /app-phone/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | -------------------------------------------------------------------------------- /app-watch/src/main/res/xml/watch_face.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | plugins { 3 | id("com.android.application") apply false 4 | id("com.android.library") apply false 5 | kotlin("android") apply false 6 | alias(libs.plugins.roborazzi) apply false 7 | } 8 | -------------------------------------------------------------------------------- /convention-plugins/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /convention-plugins/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | google() 7 | mavenCentral() 8 | gradlePluginPortal() 9 | mavenLocal() 10 | } 11 | 12 | 13 | dependencies { 14 | fun plugin(id: String, version: String) = "$id:$id.gradle.plugin:$version" 15 | implementation { 16 | Android.tools.build.gradlePlugin() 17 | Kotlin.gradlePlugin() 18 | plugin("org.jetbrains.kotlin.plugin.compose", "_")() 19 | Google.playServicesGradlePlugin() 20 | Firebase.crashlyticsGradlePlugin() 21 | plugin("de.fayard.refreshVersions", "_")() 22 | plugin("org.splitties.dependencies-dsl", "_")() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /convention-plugins/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.parallel=true 2 | org.gradle.caching=true 3 | org.gradle.configuration-cache=true 4 | 5 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 6 | # When configured, Gradle will run in incubating parallel mode. 7 | # This option should only be used with decoupled projects. For more details, visit 8 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects 9 | # org.gradle.parallel=true 10 | # AndroidX package structure to make it clearer which packages are bundled with the 11 | # Android operating system, and which are packaged with your app's APK 12 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 13 | android.useAndroidX=true 14 | # Kotlin code style for this project: "official" or "obsolete": 15 | kotlin.code.style=official 16 | # Enables namespacing of each library's R class so that its R class includes only the 17 | # resources declared in the library itself and none from the library's dependencies, 18 | # thereby reducing the size of the R class for that library 19 | android.nonTransitiveRClass=true 20 | 21 | kotlin.daemon.useFallbackStrategy=false 22 | 23 | android.defaults.buildfeatures.aidl=false 24 | android.defaults.buildfeatures.renderscript=false 25 | 26 | -------------------------------------------------------------------------------- /convention-plugins/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | mavenCentral() 4 | gradlePluginPortal() 5 | mavenLocal() 6 | } 7 | plugins { 8 | id("org.splitties.dependencies-dsl") version "0.1.0" 9 | id("de.fayard.refreshVersions") version "0.60.5" 10 | } 11 | } 12 | 13 | plugins { 14 | id("org.splitties.dependencies-dsl") 15 | id("de.fayard.refreshVersions") 16 | } 17 | 18 | refreshVersions { 19 | versionsPropertiesFile = rootDir.parentFile.resolve("versions.properties") 20 | } 21 | -------------------------------------------------------------------------------- /convention-plugins/src/main/kotlin/Versioning.kt: -------------------------------------------------------------------------------- 1 | package convention_plugins 2 | 3 | import com.android.build.gradle.internal.dsl.BaseAppModuleExtension 4 | import org.gradle.api.Project 5 | import org.gradle.kotlin.dsl.the 6 | 7 | enum class AppKind { 8 | Phone, 9 | Watch 10 | } 11 | 12 | fun Project.setVersionCodeForApp(kind: AppKind) { 13 | val android = the() 14 | android.defaultConfig.versionCode = versionCode(kind) 15 | } 16 | 17 | private fun Project.versionCode(kind: AppKind): Int { 18 | val releaseBuildNumber = rootProject.layout.projectDirectory.file( 19 | "releaseBuildNumber.txt" 20 | ).asFile.useLines { 21 | it.first() 22 | }.toInt().also { require(it > 0) } 23 | return releaseBuildNumber * 2 - when (kind) { 24 | AppKind.Phone -> 1 25 | AppKind.Watch -> 0 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /convention-plugins/src/main/kotlin/android-app.gradle.kts: -------------------------------------------------------------------------------- 1 | import java.io.FileNotFoundException 2 | 3 | plugins { 4 | kotlin("android") 5 | id("com.android.application") 6 | kotlin("plugin.compose") 7 | } 8 | 9 | android { 10 | compileSdk = 34 11 | defaultConfig { 12 | minSdk = 26 13 | targetSdk = 33 14 | resourceConfigurations += setOf("en", "fr") 15 | } 16 | // We embed the signing keystore to allow updating from another computer without uninstalling. 17 | val debugSigningConfig by signingConfigs.creating { 18 | storeFile = rootProject.file("debug.keystore") 19 | storePassword = "android" 20 | keyAlias = "androiddebugkey" 21 | keyPassword = "android" 22 | } 23 | val keystoreFile = (findProperty("androidKeyFile") as String?)?.let { keyFilePath -> 24 | File(System.getProperty("user.home")).resolve(keyFilePath).also { 25 | if (it.exists().not()) throw FileNotFoundException("Didn't find keystore file at $it") 26 | } 27 | } 28 | val releaseSigningConfig = keystoreFile?.let { 29 | signingConfigs.create("releaseSigningConfig") { 30 | val androidKeyFile: String by project 31 | storeFile = File(System.getProperty("user.home")).resolve(androidKeyFile) 32 | val androidKeyAlias: String by project 33 | val androidKeyUniversalMdp: String by project 34 | storePassword = androidKeyUniversalMdp 35 | keyAlias = androidKeyAlias 36 | keyPassword = androidKeyUniversalMdp 37 | } 38 | } 39 | buildTypes { 40 | debug { 41 | applicationIdSuffix = ".debug" 42 | signingConfig = debugSigningConfig 43 | } 44 | create("staging") { 45 | applicationIdSuffix = ".staging" 46 | matchingFallbacks += "release" 47 | isMinifyEnabled = true 48 | isShrinkResources = true 49 | isDebuggable = false 50 | signingConfig = debugSigningConfig 51 | } 52 | release { 53 | isMinifyEnabled = true 54 | isShrinkResources = true 55 | signingConfig = releaseSigningConfig ?: debugSigningConfig 56 | proguardFiles( 57 | getDefaultProguardFile("proguard-android-optimize.txt"), 58 | "proguard-rules.pro" 59 | ) 60 | } 61 | } 62 | kotlinOptions { 63 | jvmTarget = "1.8" 64 | freeCompilerArgs += "-Xcontext-receivers" 65 | } 66 | compileOptions { 67 | sourceCompatibility = JavaVersion.VERSION_1_8 68 | targetCompatibility = JavaVersion.VERSION_1_8 69 | } 70 | buildFeatures.compose = true 71 | packagingOptions.resources { 72 | excludes += setOf( 73 | "META-INF/ASL2.0", 74 | "META-INF/AL2.0", 75 | "META-INF/LGPL2.1", 76 | "META-INF/LICENSE", 77 | "META-INF/license.txt", 78 | "META-INF/NOTICE", 79 | "META-INF/notice.txt" 80 | ) 81 | 82 | // Exclude files that unused kotlin-reflect would need, to make the app smaller: 83 | // (see issue https://youtrack.jetbrains.com/issue/KT-9770) 84 | excludes += setOf( 85 | "META-INF/*.kotlin_module", 86 | "kotlin/*.kotlin_builtins", 87 | "kotlin/**/*.kotlin_builtins" 88 | ) 89 | } 90 | } 91 | 92 | dependencies { 93 | implementation { 94 | AndroidX.activity.compose() 95 | 96 | AndroidX.compose.runtime() 97 | AndroidX.compose.foundation() 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /convention-plugins/src/main/kotlin/android-crashlytics.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("android") 3 | id("com.android.application") 4 | id("com.google.gms.google-services") 5 | id("com.google.firebase.crashlytics") 6 | } 7 | 8 | dependencies { 9 | implementation { 10 | platform(Firebase.bom)() 11 | Firebase.analyticsKtx() 12 | Firebase.crashlyticsKtx() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /convention-plugins/src/main/kotlin/android-lib.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("android") 3 | id("com.android.library") 4 | } 5 | 6 | android { 7 | compileSdk = 34 8 | defaultConfig { 9 | minSdk = 23 10 | } 11 | buildTypes { 12 | release { 13 | isMinifyEnabled = false 14 | } 15 | } 16 | compileOptions { 17 | sourceCompatibility = JavaVersion.VERSION_1_8 18 | targetCompatibility = JavaVersion.VERSION_1_8 19 | } 20 | kotlinOptions { 21 | jvmTarget = "1.8" 22 | freeCompilerArgs += "-Xcontext-receivers" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /convention-plugins/src/main/kotlin/android-signing-config.gradle.kts: -------------------------------------------------------------------------------- 1 | import java.io.FileNotFoundException 2 | 3 | plugins { 4 | id("com.android.application") 5 | } 6 | 7 | android { 8 | val debugSigningConfig by signingConfigs.creating { 9 | storeFile = rootProject.file("debug.keystore") 10 | storePassword = "android" 11 | keyAlias = "androiddebugkey" 12 | keyPassword = "android" 13 | } 14 | val keystoreFile = (property("androidKeyFile") as String?)?.let { keyFilePath -> 15 | File(System.getProperty("user.home")).resolve(keyFilePath).also { 16 | if (it.exists().not()) throw FileNotFoundException("Didn't find keystore file at $it") 17 | } 18 | } 19 | val releaseSigningConfig = keystoreFile?.let { 20 | signingConfigs.create("releaseSigningConfig") { 21 | val androidKeyFile: String by project 22 | storeFile = File(System.getProperty("user.home")).resolve(androidKeyFile) 23 | val androidKeyAlias: String by project 24 | val androidKeyUniversalMdp: String by project 25 | storePassword = androidKeyUniversalMdp 26 | keyAlias = androidKeyAlias 27 | keyPassword = androidKeyUniversalMdp 28 | } 29 | } 30 | buildTypes { 31 | debug { 32 | signingConfig = debugSigningConfig 33 | } 34 | release { 35 | signingConfig = releaseSigningConfig ?: debugSigningConfig 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /convention-plugins/src/main/kotlin/version-code-phone.gradle.kts: -------------------------------------------------------------------------------- 1 | import convention_plugins.AppKind 2 | import convention_plugins.setVersionCodeForApp 3 | 4 | setVersionCodeForApp(AppKind.Phone) 5 | -------------------------------------------------------------------------------- /convention-plugins/src/main/kotlin/version-code-watch.gradle.kts: -------------------------------------------------------------------------------- 1 | import convention_plugins.AppKind 2 | import convention_plugins.setVersionCodeForApp 3 | 4 | setVersionCodeForApp(AppKind.Watch) 5 | -------------------------------------------------------------------------------- /debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/debug.keystore -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.parallel=true 2 | org.gradle.caching=true 3 | org.gradle.configuration-cache=true 4 | 5 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 6 | # When configured, Gradle will run in incubating parallel mode. 7 | # This option should only be used with decoupled projects. For more details, visit 8 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects 9 | # org.gradle.parallel=true 10 | # AndroidX package structure to make it clearer which packages are bundled with the 11 | # Android operating system, and which are packaged with your app's APK 12 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 13 | android.useAndroidX=true 14 | # Kotlin code style for this project: "official" or "obsolete": 15 | kotlin.code.style=official 16 | # Enables namespacing of each library's R class so that its R class includes only the 17 | # resources declared in the library itself and none from the library's dependencies, 18 | # thereby reducing the size of the R class for that library 19 | android.nonTransitiveRClass=true 20 | 21 | kotlin.daemon.useFallbackStrategy=false 22 | 23 | android.defaults.buildfeatures.aidl=false 24 | android.defaults.buildfeatures.renderscript=false 25 | 26 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [plugins] 2 | 3 | roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } 4 | 5 | [versions] 6 | composeOClock = "0.2.0-SNAPSHOT" 7 | roborazzi = "1.10.1" 8 | 9 | [libraries] 10 | compose-oclock-core = { group = "org.splitties.compose.oclock", name = "core", version.ref = "composeOClock" } 11 | compose-oclock-watchface-renderer = { group = "org.splitties.compose.oclock", name = "watchface-renderer", version.ref = "composeOClock" } 12 | roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" } 13 | roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" } 14 | roborazzi-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" } 15 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /releaseBuildNumber.txt: -------------------------------------------------------------------------------- 1 | 1 2 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | includeBuild("convention-plugins") 3 | repositories { 4 | google { 5 | content { 6 | includeGroupByRegex("com\\.android.*") 7 | includeGroupByRegex("com\\.google.*") 8 | includeGroupByRegex("androidx.*") 9 | } 10 | } 11 | mavenCentral() 12 | gradlePluginPortal() 13 | } 14 | } 15 | 16 | plugins { 17 | id("de.fayard.refreshVersions") version "0.60.5" 18 | id("org.splitties.settings-include-dsl") version "0.2.6" 19 | id("org.splitties.dependencies-dsl") version "0.2.0" 20 | id("org.splitties.version-sync") version "0.2.6" 21 | } 22 | 23 | run { // Remove when https://github.com/gradle/gradle/issues/2534 is fixed. 24 | val rootProjectPropertiesFile = rootDir.resolve("gradle.properties") 25 | val includedBuildPropertiesFile = rootDir.resolve("convention-plugins").resolve("gradle.properties") 26 | if (includedBuildPropertiesFile.exists().not() || 27 | rootProjectPropertiesFile.readText() != includedBuildPropertiesFile.readText() 28 | ) { 29 | rootProjectPropertiesFile.copyTo(target = includedBuildPropertiesFile, overwrite = true) 30 | } 31 | } 32 | 33 | @Suppress("UnstableApiUsage") 34 | dependencyResolutionManagement { 35 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 36 | repositories { 37 | google() 38 | mavenCentral() 39 | maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots") 40 | } 41 | } 42 | 43 | rootProject.name = "ComposeOClock sample" 44 | 45 | include { 46 | "app-phone"() 47 | "app-watch"() 48 | "shared"() 49 | } 50 | -------------------------------------------------------------------------------- /shared/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | plugins { 4 | id("android-lib") 5 | kotlin("plugin.compose") 6 | alias(libs.plugins.roborazzi) 7 | } 8 | 9 | android { 10 | namespace = "com.louiscad.composeoclockplayground.shared" 11 | 12 | defaultConfig { 13 | minSdk = 26 14 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 15 | consumerProguardFiles("consumer-rules.pro") 16 | } 17 | buildFeatures { 18 | compose = true 19 | buildConfig = true 20 | } 21 | compileOptions { 22 | isCoreLibraryDesugaringEnabled = true 23 | } 24 | 25 | testOptions { 26 | unitTests { 27 | isIncludeAndroidResources = true 28 | } 29 | } 30 | } 31 | 32 | dependencies { 33 | api { 34 | libs.compose.oclock.core() 35 | Google.android.playServices.wearOS() 36 | 37 | AndroidX.compose.ui() 38 | AndroidX.compose.ui.graphics() 39 | AndroidX.compose.ui.toolingPreview() 40 | AndroidX.compose.ui.text.googleFonts() 41 | 42 | AndroidX.core.ktx() 43 | AndroidX.lifecycle.runtime.ktx() 44 | } 45 | coreLibraryDesugaring(Android.tools.desugarJdkLibs) 46 | implementation { 47 | Splitties.systemservices() 48 | Splitties.appctx() 49 | Splitties.bitflags() 50 | } 51 | debugImplementation { 52 | AndroidX.compose.ui.tooling() //Important so previews can work. 53 | AndroidX.compose.ui.testManifest() // import for tests 54 | } 55 | testImplementation { 56 | Testing.junit4() 57 | Testing.robolectric() 58 | AndroidX.test.ext.junit.ktx() 59 | AndroidX.test.runner() 60 | AndroidX.compose.ui.testJunit4() 61 | libs.roborazzi() 62 | libs.roborazzi.compose() 63 | libs.roborazzi.rule() 64 | } 65 | androidTestImplementation { 66 | AndroidX.test.ext.junit.ktx() 67 | AndroidX.test.espresso.core() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /shared/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/consumer-rules.pro -------------------------------------------------------------------------------- /shared/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /shared/src/androidTest/java/com/louiscad/shared/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.louiscad.shared 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.louiscad.shared.test", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/ComposeOClockWatermark.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.produceState 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.geometry.Offset 8 | import androidx.compose.ui.geometry.Rect 9 | import androidx.compose.ui.graphics.Brush 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.graphics.Path 12 | import androidx.compose.ui.graphics.SolidColor 13 | import androidx.compose.ui.platform.LocalContext 14 | import androidx.compose.ui.text.TextStyle 15 | import androidx.compose.ui.text.font.AndroidFont 16 | import androidx.compose.ui.text.font.FontFamily 17 | import androidx.compose.ui.text.font.FontWeight 18 | import androidx.compose.ui.text.googlefonts.Font 19 | import androidx.compose.ui.text.googlefonts.GoogleFont 20 | import androidx.compose.ui.text.style.TextAlign 21 | import androidx.compose.ui.text.style.TextMotion 22 | import androidx.compose.ui.tooling.preview.PreviewParameter 23 | import androidx.compose.ui.unit.Dp 24 | import androidx.compose.ui.unit.dp 25 | import androidx.compose.ui.unit.sp 26 | import org.splitties.compose.oclock.LocalIsAmbient 27 | import org.splitties.compose.oclock.OClockCanvas 28 | import org.splitties.compose.oclock.sample.extensions.drawTextOnPath 29 | import org.splitties.compose.oclock.sample.extensions.rememberStateWithSize 30 | import org.splitties.compose.oclock.sample.extensions.text.rememberTextOnPathMeasurer 31 | 32 | @Composable 33 | fun ComposeOClockWatermark(finalBrush: Brush) { 34 | val font = remember { 35 | Font( 36 | googleFont = GoogleFont("Jost"), 37 | // googleFont = GoogleFont("Raleway"), 38 | // googleFont = GoogleFont("Reem Kufi"), 39 | // googleFont = GoogleFont("Montez"), 40 | fontProvider = googleFontProvider, 41 | ) as AndroidFont 42 | } 43 | val fontFamily = remember(font) { FontFamily(font) } 44 | val context = LocalContext.current 45 | val brush by produceState(finalBrush, font) { 46 | val result = runCatching { 47 | font.typefaceLoader.awaitLoad(context, font) 48 | } 49 | value = if (result.isSuccess) finalBrush else SolidColor(Color.Red) 50 | } 51 | val interactiveTextStyle = remember(fontFamily, brush) { 52 | TextStyle( 53 | brush = brush, 54 | fontFamily = fontFamily, 55 | fontSize = 16.sp, 56 | fontWeight = FontWeight.W600, 57 | textAlign = TextAlign.Center, 58 | lineHeight = 20.sp, 59 | textMotion = TextMotion.Animated 60 | ) 61 | } 62 | val ambientTextStyle = rememberStateWithSize(interactiveTextStyle) { 63 | interactiveTextStyle.copy(fontWeight = FontWeight.W300) 64 | } 65 | val textMeasurer = rememberTextOnPathMeasurer(cacheSize = 0) 66 | val isAmbient by LocalIsAmbient.current 67 | val cachedPath = remember { Path() }.let { path -> 68 | rememberStateWithSize { 69 | val minimumInset = interactiveTextStyle.fontSize.toPx() 70 | path.arcTo( 71 | rect = Rect(Offset.Zero, size).deflate(minimumInset + 4.dp.toPx()), 72 | startAngleDegrees = 89.5f, 73 | sweepAngleDegrees = 359f, 74 | forceMoveTo = true 75 | ) 76 | path 77 | } 78 | } 79 | val string = "It's Compose O'Clock!" 80 | val interactiveText = rememberStateWithSize { 81 | textMeasurer.measure( 82 | text = string, 83 | style = interactiveTextStyle 84 | ) 85 | } 86 | val ambientText = rememberStateWithSize { 87 | textMeasurer.measure( 88 | text = string, 89 | style = ambientTextStyle.get() 90 | ) 91 | } 92 | OClockCanvas { 93 | val text = if (isAmbient) ambientText else interactiveText 94 | drawTextOnPath( 95 | textLayoutResult = text.get(), 96 | path = cachedPath.get() 97 | ) 98 | } 99 | } 100 | 101 | @WatchFacePreview 102 | @Composable 103 | private fun ComposeOClockWatermarkPreview( 104 | @PreviewParameter(WearPreviewSizes::class) size: Dp 105 | ) = WatchFacePreview(size) { 106 | ComposeOClockWatermark(SolidColor(Color.Magenta)) 107 | } 108 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/GoogleFonts.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample 2 | 3 | import androidx.compose.ui.text.googlefonts.GoogleFont 4 | import com.louiscad.composeoclockplayground.shared.R 5 | 6 | val googleFontProvider = GoogleFont.Provider( 7 | providerAuthority = "com.google.android.gms.fonts", 8 | providerPackage = "com.google.android.gms", 9 | certificates = R.array.com_google_android_gms_fonts_certs 10 | ) 11 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/WatchFacePreview.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.animateColorAsState 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.requiredSize 11 | import androidx.compose.foundation.shape.CircleShape 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.collectAsState 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.mutableStateOf 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.runtime.setValue 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.draw.clip 20 | import androidx.compose.ui.graphics.Color 21 | import androidx.compose.ui.tooling.preview.Preview 22 | import androidx.compose.ui.tooling.preview.PreviewParameterProvider 23 | import androidx.compose.ui.unit.Dp 24 | import androidx.compose.ui.unit.dp 25 | import androidx.wear.watchface.complications.data.ComplicationData 26 | import kotlinx.coroutines.flow.* 27 | import org.splitties.compose.oclock.ExperimentalComposeOClockApi 28 | import org.splitties.compose.oclock.OClockRootCanvas 29 | import org.splitties.compose.oclock.PreviewTime 30 | 31 | class WearPreviewSizes : PreviewParameterProvider { 32 | override val values: Sequence = sequenceOf( 33 | WatchFacePreview.Size.small, 34 | WatchFacePreview.Size.large 35 | ) 36 | } 37 | 38 | class WearSmallest : PreviewParameterProvider { 39 | override val values: Sequence = sequenceOf( 40 | WatchFacePreview.Size.small 41 | ) 42 | } 43 | 44 | /** 45 | * Example usage: 46 | * 47 | * ```kotlin 48 | * @WatchFacePreview 49 | * @Composable 50 | * private fun MyFunClockPreview( 51 | * @PreviewParameter(WearPreviewSizes::class) size: Dp 52 | * ) = WatchFacePreview(size) { 53 | * MyFunClock() 54 | * } 55 | * ``` 56 | */ 57 | @Preview 58 | annotation class WatchFacePreview { 59 | object Size { 60 | val small = 192.dp 61 | val large = 227.dp 62 | } 63 | } 64 | 65 | @Composable 66 | fun WatchFacePreview( 67 | size: Dp, 68 | @OptIn(ExperimentalComposeOClockApi::class) 69 | previewTime: PreviewTime.Config = PreviewTime.rememberConfig(), 70 | content: @Composable (complicationData: Map>) -> Unit 71 | ) { 72 | val spacing = 8.dp 73 | var touched by remember { mutableStateOf(false) } 74 | val isAmbientFlow = remember { MutableStateFlow(false) } 75 | val isAmbient by isAmbientFlow.collectAsState() 76 | val backgroundColor by animateColorAsState( 77 | targetValue = when { 78 | touched.not() -> Color.Transparent 79 | isAmbient -> Color.DarkGray 80 | else -> Color.LightGray 81 | } 82 | ) 83 | Column( 84 | modifier = Modifier.background(backgroundColor).clickable { 85 | touched = true 86 | isAmbientFlow.update { it.not() } 87 | }.padding(spacing), 88 | verticalArrangement = Arrangement.spacedBy(spacing) 89 | ) { 90 | val modifier = Modifier.requiredSize(size).clip(CircleShape) 91 | OClockRootCanvas( 92 | modifier = modifier, 93 | previewTime = previewTime, 94 | isAmbientFlow = isAmbientFlow 95 | ) { content(it) } 96 | AnimatedVisibility(touched.not()) { 97 | OClockRootCanvas( 98 | modifier = modifier, 99 | previewTime = previewTime, 100 | isAmbientFlow = remember { MutableStateFlow(true) } 101 | ) { content(it) } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/cleanthisbeforerelease/complications/PixelWatchStyleComplication.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.cleanthisbeforerelease.complications 2 | 3 | import android.app.PendingIntent 4 | import android.os.Build 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.NoLiveLiterals 7 | import androidx.compose.runtime.collectAsState 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.ui.graphics.BlendMode 10 | import androidx.compose.ui.graphics.StrokeCap 11 | import androidx.compose.ui.graphics.drawscope.Stroke 12 | import androidx.compose.ui.platform.LocalContext 13 | import androidx.compose.ui.text.TextStyle 14 | import androidx.compose.ui.text.drawText 15 | import androidx.wear.watchface.complications.data.ComplicationData 16 | import androidx.wear.watchface.complications.data.EmptyComplicationData 17 | import androidx.wear.watchface.complications.data.GoalProgressComplicationData 18 | import androidx.wear.watchface.complications.data.LongTextComplicationData 19 | import androidx.wear.watchface.complications.data.MonochromaticImageComplicationData 20 | import androidx.wear.watchface.complications.data.NoDataComplicationData 21 | import androidx.wear.watchface.complications.data.NoPermissionComplicationData 22 | import androidx.wear.watchface.complications.data.NotConfiguredComplicationData 23 | import androidx.wear.watchface.complications.data.PhotoImageComplicationData 24 | import androidx.wear.watchface.complications.data.RangedValueComplicationData 25 | import androidx.wear.watchface.complications.data.ShortTextComplicationData 26 | import androidx.wear.watchface.complications.data.SmallImageComplicationData 27 | import androidx.wear.watchface.complications.data.WeightedElementsComplicationData 28 | import kotlinx.coroutines.flow.* 29 | import org.splitties.compose.oclock.LocalTime 30 | import org.splitties.compose.oclock.OClockCanvas 31 | import org.splitties.compose.oclock.complications.rememberDrawableAsState 32 | import org.splitties.compose.oclock.complications.rememberMeasuredAsState 33 | import org.splitties.compose.oclock.sample.extensions.topCenterAsTopLeft 34 | 35 | @NoLiveLiterals // Because compilation fails otherwise. TODO: Report issue 36 | @Composable 37 | fun PixelWatchStyleComplication( 38 | complicationDataFlow: StateFlow, 39 | textStyle: TextStyle, 40 | sizeRatio: Float 41 | ) { 42 | val time = LocalTime.current 43 | val resources = LocalContext.current.resources 44 | val complicationData by complicationDataFlow.collectAsState() 45 | OClockCanvas(onTap = { 46 | try { 47 | complicationData.tapAction?.send() 48 | } catch (e: PendingIntent.CanceledException) { 49 | // In case the PendingIntent is no longer able to execute the request. 50 | // We don't need to do anything here. 51 | } 52 | false 53 | }) {} 54 | when (val data = complicationData) { 55 | is EmptyComplicationData -> {} 56 | is NoDataComplicationData -> { 57 | data.placeholder 58 | data.contentDescription 59 | } 60 | is NoPermissionComplicationData -> { 61 | data.rememberDrawableAsState() 62 | data.text 63 | data.title 64 | } 65 | is NotConfiguredComplicationData -> {} 66 | is LongTextComplicationData -> { 67 | val text by data.text.rememberMeasuredAsState { string -> 68 | measure(text = string, style = textStyle) 69 | } 70 | val title by data.title.rememberMeasuredAsState("") { string -> 71 | measure(text = string, style = textStyle) 72 | } 73 | val image by data.rememberDrawableAsState() 74 | data.contentDescription 75 | } 76 | is MonochromaticImageComplicationData -> { 77 | data.rememberDrawableAsState() 78 | data.contentDescription 79 | } 80 | is PhotoImageComplicationData -> { 81 | data.rememberDrawableAsState() 82 | data.contentDescription 83 | } 84 | is RangedValueComplicationData -> { 85 | data.valueType 86 | RangedValueComplicationData.TYPE_RATING 87 | RangedValueComplicationData.TYPE_PERCENTAGE 88 | RangedValueComplicationData.TYPE_UNDEFINED 89 | data.min 90 | data.max 91 | data.value 92 | data.colorRamp 93 | data.title 94 | data.text 95 | data.rememberDrawableAsState() 96 | data.contentDescription 97 | val text by data.text.rememberMeasuredAsState("") { string -> 98 | measure(text = string, style = textStyle) 99 | } 100 | val title by data.title.rememberMeasuredAsState("") { string -> 101 | measure(text = string, style = textStyle) 102 | } 103 | OClockCanvas { 104 | drawArc( 105 | color = textStyle.color, 106 | startAngle = -90f, 107 | sweepAngle = 360 * data.value / data.max, 108 | useCenter = false, 109 | style = Stroke(width = 10f, cap = StrokeCap.Round), 110 | blendMode = BlendMode.Screen 111 | ) 112 | drawText( 113 | textLayoutResult = title, 114 | topLeft = center.copy( 115 | y = 20f 116 | ).topCenterAsTopLeft(title.size) 117 | ) 118 | drawText( 119 | textLayoutResult = text, 120 | topLeft = center.copy( 121 | y = 20f - title.size.height 122 | ).topCenterAsTopLeft(text.size) 123 | ) 124 | } 125 | } 126 | is ShortTextComplicationData -> { 127 | data.rememberDrawableAsState() 128 | data.text 129 | data.title 130 | data.contentDescription 131 | } 132 | is SmallImageComplicationData -> { 133 | data.rememberDrawableAsState() 134 | data.contentDescription 135 | } 136 | else -> if (Build.VERSION.SDK_INT >= 33) when (data) { 137 | is GoalProgressComplicationData -> { 138 | data.targetValue 139 | data.value 140 | data.value 141 | data.colorRamp 142 | data.text 143 | data.title 144 | data.rememberDrawableAsState() 145 | data.contentDescription 146 | } 147 | is WeightedElementsComplicationData -> { 148 | data.elements.forEach { 149 | it.color 150 | it.weight 151 | } 152 | data.elementBackgroundColor 153 | data.rememberDrawableAsState() 154 | data.title 155 | data.text 156 | data.contentDescription 157 | } 158 | else -> {} 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/cleanthisbeforerelease/elements/CirclePatterns.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements 2 | 3 | import androidx.compose.ui.geometry.Size 4 | import androidx.compose.ui.graphics.Color 5 | import androidx.compose.ui.graphics.drawscope.DrawScope 6 | import androidx.compose.ui.graphics.drawscope.Stroke 7 | import androidx.compose.ui.graphics.drawscope.rotate 8 | import androidx.compose.ui.unit.dp 9 | import org.splitties.compose.oclock.sample.extensions.drawOval 10 | import org.splitties.compose.oclock.sample.extensions.rotate 11 | import kotlin.math.PI 12 | import kotlin.math.sin 13 | 14 | fun DrawScope.circlesPattern( 15 | color: Color = Color.White.copy(alpha = .4f), 16 | count: Int = 90, 17 | startAngle: Float = 0f, 18 | endAngle: Float = 360f, 19 | diameters: Float, 20 | edgeMargin: Float = 0f, 21 | ) { 22 | val stroke = Stroke(width = 1.dp.toPx()) 23 | val radius = diameters / 2 24 | for (i in 1..count) { 25 | val angle = 360f * i / count 26 | if (angle < startAngle) continue 27 | if (angle > endAngle) return 28 | drawCircle( 29 | color = color, 30 | style = stroke, 31 | radius = radius, 32 | center = center.copy(y = edgeMargin + stroke.width / 2 + radius).rotate(angle) 33 | ) 34 | } 35 | } 36 | 37 | fun DrawScope.wavyCirclesPattern( 38 | color: Color = Color.White.copy(alpha = .4f), 39 | count: Int = 90, 40 | startAngle: Float = 0f, 41 | endAngle: Float = 360f, 42 | diameters: Float, 43 | travel: Float = diameters / 2f, 44 | periodAngle: Float = 30f, 45 | edgeMargin: Float = 0f, 46 | ) { 47 | val stroke = Stroke(width = 1.dp.toPx()) 48 | val radius = diameters / 2 49 | for (i in 1..count) { 50 | val angle = 360f * i / count 51 | if (angle < startAngle) continue 52 | if (angle > endAngle) return 53 | val periodicOffset = (travel * (sin(2 * PI * (angle % periodAngle) / periodAngle - PI / 2) + 1)) / 2 54 | val y = edgeMargin + stroke.width / 2 + radius + periodicOffset.toFloat() 55 | drawCircle( 56 | color = color, 57 | style = stroke, 58 | radius = radius, 59 | center = center.copy(y = y).rotate(angle) 60 | ) 61 | } 62 | } 63 | 64 | fun DrawScope.circlesPattern( 65 | color: Color = Color.White.copy(alpha = .4f), 66 | count: Int = 90, 67 | diameters: List, 68 | edgeMargin: Float = 0f, 69 | ) { 70 | val stroke = Stroke(width = 1.dp.toPx()) 71 | diameters.forEach { diameter -> 72 | val radius = diameter / 2 73 | repeat(count) { i -> 74 | val angle = 360f * i / count 75 | drawCircle( 76 | color = color, 77 | style = stroke, 78 | radius = radius, 79 | center = center.copy(y = edgeMargin + stroke.width / 2 + radius).rotate(angle) 80 | ) 81 | } 82 | } 83 | } 84 | 85 | fun DrawScope.ovalPattern( 86 | color: Color = Color.White.copy(alpha = .4f), 87 | count: Int = 90, 88 | size: Size, 89 | edgeMargin: Float = 0f, 90 | ) { 91 | val stroke = Stroke(width = 1.dp.toPx()) 92 | repeat(count) { i -> 93 | rotate(degrees = 360f * i / count) { 94 | drawOval( 95 | color = color, 96 | style = stroke, 97 | size = size, 98 | center = center.copy(y = edgeMargin + stroke.width / 2 + size.height / 2) 99 | ) 100 | } 101 | } 102 | } 103 | 104 | fun DrawScope.straightOvalPattern( 105 | color: Color = Color.White.copy(alpha = .4f), 106 | count: Int = 90, 107 | size: Size, 108 | edgeMargin: Float = 0f, 109 | ) { 110 | val stroke = Stroke(width = 1.dp.toPx()) 111 | repeat(count) { i -> 112 | drawOval( 113 | color = color, 114 | style = stroke, 115 | size = size, 116 | center = center.copy( 117 | y = edgeMargin + stroke.width / 2 + size.height / 2 118 | ).rotate(degrees = 360f * i / count) 119 | ) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/cleanthisbeforerelease/elements/LinePatterns.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.graphics.drawscope.DrawScope 5 | import androidx.compose.ui.unit.dp 6 | import org.splitties.compose.oclock.sample.extensions.rotate 7 | 8 | fun DrawScope.linesPattern( 9 | color: Color = Color.White.copy(alpha = .4f), 10 | count: Int = 90 11 | ) = circularRepeat(count) { degrees -> 12 | drawLine( 13 | color = color, 14 | strokeWidth = 1.dp.toPx(), 15 | start = center.copy(y = 0f).rotate(degrees = degrees), 16 | end = center.copy(y = 50.dp.toPx()).rotate(degrees = degrees), 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/cleanthisbeforerelease/elements/PatternHelpers.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements 2 | 3 | inline fun circularRepeat( 4 | count: Int, 5 | block: (angleInDegrees: Float) -> Unit 6 | ) { 7 | repeat(count) { i -> 8 | block(360f * i / count) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/cleanthisbeforerelease/experiments/TextOnPathExperiments.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.cleanthisbeforerelease.experiments 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.produceState 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.geometry.Offset 8 | import androidx.compose.ui.geometry.Rect 9 | import androidx.compose.ui.graphics.Brush 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.graphics.Paint 12 | import androidx.compose.ui.graphics.PaintingStyle 13 | import androidx.compose.ui.graphics.Path 14 | import androidx.compose.ui.graphics.PathEffect 15 | import androidx.compose.ui.graphics.SolidColor 16 | import androidx.compose.ui.graphics.drawscope.Stroke 17 | import androidx.compose.ui.platform.LocalContext 18 | import androidx.compose.ui.text.TextStyle 19 | import androidx.compose.ui.text.font.AndroidFont 20 | import androidx.compose.ui.text.font.FontFamily 21 | import androidx.compose.ui.text.font.FontWeight 22 | import androidx.compose.ui.text.googlefonts.Font 23 | import androidx.compose.ui.text.googlefonts.GoogleFont 24 | import androidx.compose.ui.text.style.TextAlign 25 | import androidx.compose.ui.text.style.TextGeometricTransform 26 | import androidx.compose.ui.text.style.TextMotion 27 | import androidx.compose.ui.tooling.preview.PreviewParameter 28 | import androidx.compose.ui.unit.Dp 29 | import androidx.compose.ui.unit.dp 30 | import androidx.compose.ui.unit.sp 31 | import androidx.compose.ui.util.fastForEachIndexed 32 | import org.splitties.compose.oclock.LocalIsAmbient 33 | import org.splitties.compose.oclock.OClockCanvas 34 | import org.splitties.compose.oclock.sample.WatchFacePreview 35 | import org.splitties.compose.oclock.sample.WearPreviewSizes 36 | import org.splitties.compose.oclock.sample.extensions.centerAsTopLeft 37 | import org.splitties.compose.oclock.sample.extensions.drawTextOnPath 38 | import org.splitties.compose.oclock.sample.extensions.rememberStateWithSize 39 | import org.splitties.compose.oclock.sample.extensions.setFrom 40 | import org.splitties.compose.oclock.sample.extensions.text.rememberTextOnPathMeasurer 41 | import org.splitties.compose.oclock.sample.googleFontProvider 42 | 43 | @Composable 44 | fun TextOnPathExperiment(finalBrush: Brush) { 45 | val font = remember { 46 | Font( 47 | googleFont = GoogleFont("Jost"), 48 | // googleFont = GoogleFont("Raleway"), 49 | // googleFont = GoogleFont("Reem Kufi"), 50 | // googleFont = GoogleFont("Montez"), 51 | fontProvider = googleFontProvider, 52 | ) as AndroidFont 53 | } 54 | val fontFamily = remember(font) { FontFamily(font) } 55 | val context = LocalContext.current 56 | val brush by produceState(finalBrush, font) { 57 | val result = runCatching { 58 | font.typefaceLoader.awaitLoad(context, font) 59 | } 60 | value = if (result.isSuccess) finalBrush else Brush.linearGradient(listOf(Color.Red)) 61 | } 62 | val textStyle = remember(fontFamily, brush) { 63 | TextStyle( 64 | brush = null, 65 | fontFamily = fontFamily, 66 | fontSize = 16.sp, 67 | fontWeight = FontWeight.W600, 68 | textAlign = TextAlign.Start, 69 | lineHeight = 20.sp, 70 | textMotion = TextMotion.Animated, 71 | textGeometricTransform = TextGeometricTransform( 72 | scaleX = 1f, 73 | skewX = Float.MIN_VALUE 74 | ) 75 | ) 76 | } 77 | val textMeasurer = rememberTextOnPathMeasurer(cacheSize = 0) 78 | val isAmbient by LocalIsAmbient.current 79 | val cachedPath = remember { Path() }.let { path -> 80 | rememberStateWithSize { 81 | path.arcTo( 82 | rect = Rect(Offset.Zero, size).deflate(textStyle.fontSize.toPx()), 83 | startAngleDegrees = -180f, 84 | sweepAngleDegrees = 359f, 85 | forceMoveTo = true 86 | ) 87 | path 88 | } 89 | } 90 | val txt = "Hello Romain! Bonjour Romain!".repeat(2) 91 | val txtList = remember { 92 | txt.map { c -> 93 | val str = c.toString() 94 | textMeasurer.measure( 95 | text = str, 96 | style = textStyle.copy(Color.Red) 97 | ) 98 | } 99 | } 100 | val text = remember { 101 | textMeasurer.measure( 102 | text = txt, 103 | style = textStyle.copy(Color.White.copy(alpha = .5f)) 104 | ) 105 | } 106 | val colors = remember { listOf(Color.Gray, Color.White) } 107 | val paint = rememberStateWithSize { 108 | Paint().also { 109 | it.pathEffect = PathEffect.cornerPathEffect(50f) 110 | Brush.verticalGradient(colors, 111 | startY = center.y - size.height / 6f, 112 | endY = center.y + size.height / 6f, 113 | ).applyTo(size, it, 1f) 114 | it.alpha = 1f 115 | it.style = PaintingStyle.Fill 116 | } 117 | } 118 | val outlinePaint = rememberStateWithSize { 119 | Paint().also { 120 | it.setFrom(paint.get()) 121 | Brush.verticalGradient( 122 | colors.asReversed(), 123 | startY = center.y - size.height / 3f, 124 | endY = center.y + size.height / 3f, 125 | ).applyTo(size, it, 1f) 126 | it.strokeWidth = 2.dp.toPx() 127 | it.style = PaintingStyle.Stroke 128 | } 129 | } 130 | OClockCanvas { 131 | val y = 0f 132 | val path = cachedPath.get() 133 | // drawPath(path, Color.Green) 134 | val s = size / 3f 135 | if (false) drawRect( 136 | brush = brush, 137 | topLeft = center.centerAsTopLeft(s), 138 | size = s, 139 | style = Stroke( 140 | width = 1.dp.toPx(), 141 | pathEffect = PathEffect.cornerPathEffect(50f) 142 | ) 143 | ) 144 | val r = Rect(center.centerAsTopLeft(s), s) 145 | drawContext.canvas.drawRect(r, paint.get()) 146 | drawContext.canvas.drawRect(r, outlinePaint.get()) 147 | txtList.fastForEachIndexed { index, textOnPathLayoutResult -> 148 | var offset: Float = 0f 149 | var i = 0 150 | while (i < index) { 151 | val current = txtList[i] 152 | offset += current.internalResult.getBoundingBox(current.layoutInput.text.lastIndex).right 153 | // offset += txtList[i].internalResult.size.width - 1 154 | i++ 155 | } 156 | drawTextOnPath( 157 | textOnPathLayoutResult, 158 | path = path, 159 | offset = Offset(x = offset, y = y), 160 | // blendMode = BlendMode.Darken 161 | ) 162 | } 163 | drawTextOnPath( 164 | text, 165 | path = path, 166 | alpha = .5f, 167 | offset = Offset(x = 0f, y = y), 168 | // blendMode = BlendMode.SrcIn 169 | ) 170 | } 171 | } 172 | 173 | @WatchFacePreview 174 | @Composable 175 | private fun TextOnPathExperimentPreview( 176 | @PreviewParameter(WearPreviewSizes::class) size: Dp 177 | ) = WatchFacePreview(size) { 178 | TextOnPathExperiment(finalBrush = SolidColor(Color.Magenta)) 179 | } 180 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/elements/ClockHand.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.elements 2 | 3 | import androidx.compose.ui.geometry.CornerRadius 4 | import androidx.compose.ui.geometry.Offset 5 | import androidx.compose.ui.geometry.Size 6 | import androidx.compose.ui.graphics.BlendMode 7 | import androidx.compose.ui.graphics.Brush 8 | import androidx.compose.ui.graphics.Color 9 | import androidx.compose.ui.graphics.drawscope.DrawScope 10 | import androidx.compose.ui.graphics.drawscope.DrawStyle 11 | import androidx.compose.ui.graphics.drawscope.Fill 12 | import androidx.compose.ui.graphics.drawscope.Stroke 13 | import androidx.compose.ui.unit.dp 14 | 15 | fun DrawScope.clockHand( 16 | color: Color, 17 | width: Float = 10.dp.toPx(), 18 | height: Float = size.height / 4f, 19 | style: DrawStyle = Fill, 20 | blendMode: BlendMode 21 | ) { 22 | val halfWidth = width / 2f 23 | val finalHeight = height + halfWidth 24 | drawRoundRect( 25 | color = color, 26 | topLeft = Offset( 27 | x = center.x - width / 2f, 28 | y = center.y - finalHeight + width / 2f 29 | ), 30 | size = Size(width, finalHeight), 31 | cornerRadius = CornerRadius(width), 32 | style = style, 33 | blendMode = blendMode 34 | ) 35 | } 36 | 37 | fun DrawScope.clockHand( 38 | brush: Brush, 39 | width: Float = 10.dp.toPx(), 40 | height: Float = size.height / 4f, 41 | style: DrawStyle = Fill, 42 | blendMode: BlendMode = BlendMode.SrcOver 43 | ) { 44 | val padding = if (style is Stroke) style.width else 0f 45 | val finalWidth = width - padding 46 | val halfWidth = finalWidth / 2f 47 | val finalHeight = height + halfWidth - padding / 2f 48 | drawRoundRect( 49 | brush = brush, 50 | topLeft = Offset( 51 | x = center.x - halfWidth, 52 | y = center.y - finalHeight + halfWidth 53 | ), 54 | size = Size(finalWidth, finalHeight), 55 | cornerRadius = CornerRadius(finalWidth), 56 | style = style, 57 | blendMode = blendMode 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/elements/FatHourDigits.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.elements 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.remember 6 | import androidx.compose.ui.graphics.BlendMode 7 | import androidx.compose.ui.graphics.Color 8 | import androidx.compose.ui.graphics.Shadow 9 | import androidx.compose.ui.graphics.drawscope.DrawStyle 10 | import androidx.compose.ui.graphics.drawscope.Fill 11 | import androidx.compose.ui.graphics.layer.GraphicsLayer 12 | import androidx.compose.ui.graphics.layer.drawLayer 13 | import androidx.compose.ui.platform.LocalDensity 14 | import androidx.compose.ui.text.TextStyle 15 | import androidx.compose.ui.text.drawText 16 | import androidx.compose.ui.text.font.Font 17 | import androidx.compose.ui.text.font.FontFamily 18 | import androidx.compose.ui.text.font.FontWeight 19 | import androidx.compose.ui.text.rememberTextMeasurer 20 | import androidx.compose.ui.unit.Dp 21 | import androidx.compose.ui.unit.TextUnit 22 | import androidx.compose.ui.unit.dp 23 | import androidx.compose.ui.unit.sp 24 | import com.louiscad.composeoclockplayground.shared.R 25 | import org.splitties.compose.oclock.LocalIsAmbient 26 | import org.splitties.compose.oclock.LocalTime 27 | import org.splitties.compose.oclock.OClockCanvas 28 | import org.splitties.compose.oclock.sample.extensions.SizeDependentState 29 | import org.splitties.compose.oclock.sample.extensions.blurOffset 30 | import org.splitties.compose.oclock.sample.extensions.center 31 | import org.splitties.compose.oclock.sample.extensions.rememberGraphicsLayerAsState 32 | import org.splitties.compose.oclock.sample.extensions.sizeForLayer 33 | 34 | @Composable 35 | fun FatHourDigits( 36 | interactiveColor: Color, 37 | interactiveShadowColor: Color = interactiveColor, 38 | ambientShadowColor: Color = interactiveShadowColor, 39 | interactiveShadowRepeat: Int = 1, 40 | ambientShadowRepeat: Int = interactiveShadowRepeat.takeIf { it > 1 } ?: 2, 41 | fontSize: TextUnit = 70.sp, 42 | interactiveBlurRadius: Dp = 10.dp, 43 | ambientBlurRadius: Dp = 10.dp 44 | ) { 45 | val interactive = fatHourDigitsLayer( 46 | textColor = interactiveColor, 47 | shadowColor = interactiveShadowColor, 48 | fontSize = fontSize, 49 | blurRadius = interactiveBlurRadius 50 | ) 51 | val ambient = fatHourDigitsLayer( 52 | textColor = Color.Black, 53 | shadowColor = ambientShadowColor, 54 | fontSize = fontSize, 55 | blurRadius = ambientBlurRadius 56 | ) 57 | val isAmbient by LocalIsAmbient.current 58 | OClockCanvas { 59 | val count = if (isAmbient) ambientShadowRepeat else interactiveShadowRepeat 60 | val layer = (if (isAmbient) ambient else interactive).get() 61 | repeat(count) { 62 | drawLayer(layer) 63 | } 64 | } 65 | } 66 | 67 | @Composable 68 | fun fatHourDigitsLayer( 69 | textColor: Color = Color.White, 70 | shadowColor: Color = Color.White, 71 | blendMode: BlendMode = BlendMode.SrcOver, 72 | fontSize: TextUnit = 70.sp, 73 | blurRadius: Dp = 10.dp 74 | ): SizeDependentState { 75 | val style = rememberFatHourDigitsTextStyle( 76 | textColor = textColor, 77 | shadowColor = shadowColor, 78 | blendMode = blendMode, 79 | fontSize = fontSize, 80 | blurRadius = blurRadius 81 | ) 82 | val hourDigits = rememberHourDigits(style) 83 | return rememberGraphicsLayerAsState { layer -> 84 | layer.blendMode = blendMode 85 | layer.record(size = hourDigits.sizeForLayer()) { 86 | drawText(hourDigits, topLeft = hourDigits.blurOffset()) 87 | } 88 | layer.center() 89 | } 90 | } 91 | 92 | @Composable 93 | fun rememberFatHourDigitsTextStyle( 94 | textColor: Color = Color.White, 95 | shadowColor: Color = Color.White, 96 | blendMode: BlendMode = BlendMode.SrcOver, 97 | fontSize: TextUnit = 70.sp, 98 | drawStyle: DrawStyle = Fill, 99 | blurRadius: Dp = 10.dp 100 | ): TextStyle { 101 | val density = LocalDensity.current 102 | val fontFamily = remember { FontFamily(Font(R.font.outfit_extrabold)) } 103 | @Suppress("name_shadowing") 104 | val blurRadius = with(density) { blurRadius.toPx() } 105 | return remember(textColor, shadowColor) { 106 | TextStyle( 107 | color = textColor, 108 | fontSize = fontSize, 109 | // fontWeight = FontWeight.ExtraBold, 110 | drawStyle = drawStyle, 111 | fontFamily = fontFamily, 112 | shadow = if (blurRadius > 0f) Shadow( 113 | color = shadowColor, 114 | blurRadius = blurRadius 115 | ) else null 116 | ) 117 | } 118 | } 119 | 120 | @Composable 121 | fun HourDigitsUnCached( 122 | textColor: Color = Color.White, 123 | shadowColor: Color = Color.White, 124 | blendMode: BlendMode = BlendMode.SrcOver, 125 | fontSize: TextUnit = 70.sp, 126 | ) { 127 | val textMeasurer = rememberTextMeasurer() 128 | val density = LocalDensity.current 129 | val style = remember(textColor, shadowColor) { 130 | val fontFamily = FontFamily( 131 | Font(R.font.outfit_extrabold) 132 | ) 133 | TextStyle( 134 | color = textColor, 135 | fontSize = fontSize, 136 | fontWeight = FontWeight.ExtraBold, 137 | fontFamily = fontFamily, 138 | shadow = Shadow( 139 | color = shadowColor, 140 | blurRadius = with(density) { 10.dp.toPx() } 141 | ) 142 | ) 143 | } 144 | val time = LocalTime.current 145 | val measuredText = remember(time.hours) { textMeasurer.measure("${time.hours}", style) } 146 | OClockCanvas { 147 | drawText( 148 | measuredText, 149 | topLeft = center.run { 150 | copy( 151 | x = x - measuredText.size.width / 2f, 152 | y = y - measuredText.size.height / 2f 153 | ) 154 | }, 155 | blendMode = blendMode 156 | ) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/elements/HourDigitsHelpers.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.elements 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import androidx.compose.ui.text.TextLayoutResult 6 | import androidx.compose.ui.text.TextStyle 7 | import androidx.compose.ui.text.rememberTextMeasurer 8 | import org.splitties.compose.oclock.LocalTime 9 | 10 | @Composable 11 | fun rememberHourDigits(textStyle: TextStyle): TextLayoutResult { 12 | val time = LocalTime.current 13 | val textMeasurer = rememberTextMeasurer() 14 | val measuredText = remember(time.hours, textStyle) { 15 | textMeasurer.measure(text = "${time.hours}", style = textStyle) 16 | } 17 | return measuredText 18 | } 19 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/elements/MyPath.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.elements 2 | 3 | import androidx.annotation.FloatRange 4 | import androidx.compose.ui.geometry.Offset 5 | import androidx.compose.ui.geometry.Rect 6 | import androidx.compose.ui.geometry.Size 7 | import androidx.compose.ui.graphics.Path 8 | import androidx.compose.ui.graphics.drawscope.Stroke 9 | import org.splitties.compose.oclock.sample.extensions.cubicTo 10 | import org.splitties.compose.oclock.sample.extensions.lineTo 11 | import org.splitties.compose.oclock.sample.extensions.moveTo 12 | import org.splitties.compose.oclock.sample.extensions.offsetBy 13 | import org.splitties.compose.oclock.sample.extensions.plus 14 | import org.splitties.compose.oclock.sample.extensions.quadraticTo 15 | import org.splitties.compose.oclock.sample.extensions.rotateAround 16 | import kotlin.math.sqrt 17 | 18 | fun Path.setToStar4( 19 | side: Float, 20 | topLeft: Offset = Offset.Zero, 21 | ) { 22 | setToStarX(count = 6, side = side, topLeft = topLeft) 23 | } 24 | 25 | fun Path.setToStarX( 26 | count: Int, 27 | side: Float, 28 | topLeft: Offset = Offset.Zero, 29 | ) { 30 | if (isEmpty.not()) reset() 31 | val half = side / 2f 32 | val topMiddle = topLeft.offsetBy(x = half) 33 | val center = topLeft + half 34 | moveTo(topMiddle) 35 | for (i in 1..count) { 36 | val target = topMiddle.rotateAround(pivot = center, degrees = 360f * i / count) 37 | quadraticTo(center, target) 38 | } 39 | close() 40 | } 41 | 42 | fun Path.setToHeart( 43 | topLeft: Offset = Offset.Zero, 44 | size: Size, 45 | @FloatRange(.0, 1.65) tipSharpnessRatio: Float = 1.1f 46 | ) { 47 | if (isEmpty.not()) reset() 48 | val halfH = size.height / 2f 49 | val halfW = size.width / 2f 50 | val topControl = halfH * .68f 51 | val tipControl = halfH * tipSharpnessRatio 52 | val topMiddle = topLeft.offsetBy( 53 | x = halfW, 54 | y = halfW * .5f 55 | ) 56 | moveTo(topMiddle) 57 | cubicTo( 58 | topMiddle.offsetBy(y = -topControl), 59 | topMiddle.offsetBy(x = -halfW, y = -topControl), 60 | topMiddle.offsetBy(x = -halfW) // Left 61 | ) 62 | arcTo(Rect(topMiddle, topMiddle.offsetBy(x = -halfW)), startAngleDegrees = 0f, -180f, forceMoveTo = false) 63 | cubicTo( 64 | topMiddle.offsetBy(x = -halfW, y = topControl), 65 | topMiddle.offsetBy(y = tipControl), 66 | topMiddle.copy(y = topLeft.y + size.height) // Bottom 67 | ) 68 | cubicTo( 69 | topMiddle.offsetBy(y = tipControl), 70 | topMiddle.offsetBy(x = halfW, y = topControl), 71 | topMiddle.offsetBy(x = halfW) // Right 72 | ) 73 | cubicTo( 74 | topMiddle.offsetBy(x = halfW, y = -topControl), 75 | topMiddle.offsetBy(y = -topControl), 76 | topMiddle // Top 77 | ) 78 | close() 79 | } 80 | 81 | fun Path.setToSketchyHeart( 82 | size: Size, 83 | topLeft: Offset = Offset.Zero, 84 | ) { 85 | if (isEmpty.not()) reset() 86 | val halfH = size.height / 2f 87 | val halfW = size.width / 2f 88 | val circlesRadius = size.width / 4f 89 | val topPoint = topLeft.offsetBy(x = halfW, y = circlesRadius) 90 | val bottomMiddle = topLeft.offsetBy(x = halfW, y = size.height) 91 | moveTo(bottomMiddle) 92 | val bottomControl = topLeft.offsetBy(x = halfW, y = size.height * .8f) 93 | val leftEdge = topLeft.offsetBy(y = size.height / 4) 94 | val rightEdge = topLeft.offsetBy(x = size.width,y = size.height / 4) 95 | cubicTo( 96 | cp1 = bottomMiddle, 97 | cp2 = bottomControl, 98 | point = bottomMiddle 99 | ) 100 | cubicTo( 101 | cp1 = leftEdge.offsetBy(y = circlesRadius), 102 | cp2 = leftEdge.offsetBy(y = - circlesRadius), 103 | point = leftEdge 104 | ) 105 | val topMiddle = topLeft.offsetBy(x = halfW) 106 | val topLeftEdge = topMiddle.offsetBy(x = -circlesRadius) 107 | val topRightEdge = topMiddle.offsetBy(x = circlesRadius) 108 | cubicTo( 109 | cp1 = topLeft, 110 | cp2 = topLeft.offsetBy(x = halfW), 111 | point = topLeftEdge 112 | ) 113 | cubicTo( 114 | cp1 = topMiddle, 115 | cp2 = topPoint, 116 | point = topPoint 117 | ) 118 | cubicTo( 119 | cp1 = topPoint, 120 | cp2 = topMiddle, 121 | point = topRightEdge 122 | ) 123 | cubicTo( 124 | cp1 = rightEdge.offsetBy(y = -circlesRadius), 125 | cp2 = rightEdge.offsetBy(y = circlesRadius), 126 | point = rightEdge 127 | ) 128 | cubicTo( 129 | cp1 = bottomControl, 130 | cp2 = bottomMiddle, 131 | point = bottomMiddle 132 | ) 133 | close() 134 | } 135 | 136 | fun Path.setToKotlinLogo( 137 | side: Float, 138 | topLeft: Offset = Offset.Zero, 139 | ) { 140 | if (isEmpty.not()) reset() 141 | moveTo(topLeft) 142 | lineTo(topLeft.offsetBy(x = side)) 143 | lineTo(topLeft.offsetBy(x = side / 2f, y = side / 2f)) 144 | lineTo(topLeft.offsetBy(x = side, y = side)) 145 | lineTo(topLeft.offsetBy(y = side)) 146 | close() 147 | } 148 | 149 | fun Path.setToKotlinLogo( 150 | side: Float, 151 | topLeft: Offset = Offset.Zero, 152 | stroke: Stroke? = null, 153 | ) { 154 | val strokeWidth = stroke?.width ?: 0f 155 | val halfStroke = strokeWidth / 2f 156 | val endOffset = if (stroke != null) sqrt(halfStroke * halfStroke * 2) else 0f 157 | if (isEmpty.not()) reset() 158 | val logoCenterX = side / 2f - endOffset 159 | moveTo(topLeft + halfStroke) 160 | lineTo(topLeft.offsetBy(x = side - endOffset - halfStroke, y = halfStroke)) 161 | lineTo(topLeft.offsetBy(x = logoCenterX, y = side / 2f)) 162 | lineTo(topLeft.offsetBy(x = side - endOffset - halfStroke, y = side - halfStroke)) 163 | lineTo(topLeft.offsetBy(x = halfStroke, y = side - halfStroke)) 164 | close() 165 | } 166 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/elements/SinusoidalCrown.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.elements 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.remember 6 | import androidx.compose.ui.geometry.Offset 7 | import androidx.compose.ui.geometry.Size 8 | import androidx.compose.ui.geometry.center 9 | import androidx.compose.ui.graphics.Brush 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.graphics.ColorFilter 12 | import androidx.compose.ui.graphics.Path 13 | import androidx.compose.ui.graphics.PathMeasure 14 | import androidx.compose.ui.graphics.SolidColor 15 | import androidx.compose.ui.graphics.StrokeCap 16 | import androidx.compose.ui.graphics.drawscope.Stroke 17 | import androidx.compose.ui.graphics.drawscope.scale 18 | import androidx.compose.ui.tooling.preview.PreviewParameter 19 | import androidx.compose.ui.unit.Dp 20 | import androidx.compose.ui.unit.dp 21 | import org.splitties.compose.oclock.LocalIsAmbient 22 | import org.splitties.compose.oclock.LocalTime 23 | import org.splitties.compose.oclock.OClockCanvas 24 | import org.splitties.compose.oclock.sample.WatchFacePreview 25 | import org.splitties.compose.oclock.sample.WearPreviewSizes 26 | import org.splitties.compose.oclock.sample.elements.vectors.rememberComposeMultiplatformVectorPainter 27 | import org.splitties.compose.oclock.sample.elements.vectors.rememberWearOsLogoVectorPainter 28 | import org.splitties.compose.oclock.sample.extensions.centerAsTopLeft 29 | import org.splitties.compose.oclock.sample.extensions.drawPainter 30 | import org.splitties.compose.oclock.sample.extensions.moveTo 31 | import org.splitties.compose.oclock.sample.extensions.quadraticTo 32 | import org.splitties.compose.oclock.sample.extensions.rememberStateWithSize 33 | import org.splitties.compose.oclock.sample.extensions.rotateAround 34 | 35 | @WatchFacePreview 36 | @Composable 37 | fun SinusoidalCrownPreview( 38 | @PreviewParameter(WearPreviewSizes::class) size: Dp 39 | ) = WatchFacePreview(size) { 40 | val time = LocalTime.current 41 | SinusoidalCrown(getRatio = { time.minutesWithSeconds / 60f }) 42 | val wearOsLogo = rememberWearOsLogoVectorPainter() 43 | val composeLogo = rememberComposeMultiplatformVectorPainter() 44 | val washingOutFilter = remember { 45 | ColorFilter.lighting( 46 | add = Color.Gray, 47 | multiply = Color.hsl(hue = 0f, saturation = 0f, lightness = .2f) 48 | ) 49 | } 50 | OClockCanvas { 51 | scale(.2f) { 52 | drawPainter( 53 | wearOsLogo, 54 | topLeft = center.centerAsTopLeft(wearOsLogo.intrinsicSize), 55 | colorFilter = washingOutFilter, 56 | alpha = .5f 57 | ) 58 | } 59 | drawPainter( 60 | painter = composeLogo, 61 | topLeft = center.centerAsTopLeft(composeLogo.intrinsicSize) 62 | ) 63 | } 64 | } 65 | 66 | @Composable 67 | fun SinusoidalCrown( 68 | getRatio: () -> Float, 69 | interactiveFillBrush: Brush = remember { SolidColor(Color.Cyan) }, 70 | ambientFillBrush: Brush = remember { SolidColor(Color.Cyan) } 71 | ) { 72 | val pathMeasure = remember { PathMeasure() } 73 | val cachedPath = remember { Path() }.let { path -> 74 | rememberStateWithSize { 75 | path.also { 76 | it.setToSineWaveLike(size) 77 | pathMeasure.setPath(it, forceClosed = false) 78 | } 79 | } 80 | } 81 | val wholePathStroke = rememberStateWithSize { 82 | Stroke(width = 1.dp.toPx()) 83 | } 84 | val progressPathStroke = rememberStateWithSize { 85 | Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round) 86 | } 87 | val segment = remember { Path() } 88 | val isAmbient by LocalIsAmbient.current 89 | OClockCanvas { 90 | val path by cachedPath 91 | val ratio = getRatio() 92 | segment.reset() 93 | pathMeasure.getSegment( 94 | startDistance = 0f, 95 | stopDistance = ratio * pathMeasure.length, 96 | destination = segment 97 | ) 98 | drawPath(path, Color.LightGray, style = wholePathStroke.get()) 99 | val fillBrush = if (isAmbient) ambientFillBrush else interactiveFillBrush 100 | drawPath(segment, fillBrush, style = progressPathStroke.get()) 101 | } 102 | } 103 | 104 | private fun Path.setToSineWaveLike(size: Size) { 105 | if (isEmpty.not()) reset() 106 | val center = size.center 107 | val amplitude = size.height / 10f 108 | val start = Offset(center.x, amplitude / 2f) 109 | moveTo(start) 110 | val count = 30 111 | for (i in 1..count) { 112 | val ratio = i.toFloat() / count 113 | val anglePeriod = 360f / count 114 | quadraticTo( 115 | p1 = start.copy(y = 0f).rotateAround(center, anglePeriod * (i - .75f)), 116 | p2 = start.rotateAround(pivot = center, degrees = anglePeriod * (i - .5f)) 117 | ) 118 | quadraticTo( 119 | p1 = start.copy(y = amplitude).rotateAround(center, anglePeriod * (i - .25f)), 120 | p2 = start.rotateAround(pivot = center, degrees = anglePeriod * (i)) 121 | ) 122 | } 123 | close() 124 | } 125 | 126 | private fun Path.setToDecoration(size: Size) { 127 | val start = Offset(size.width / 2f, 0f) 128 | moveTo(start) 129 | val amplitude = size.height / 10f 130 | val count = 30 131 | for (i in 0..count) { 132 | val ratio = i.toFloat() / count 133 | val topForward = start.rotateAround(pivot = size.center, degrees = 360f * ratio) 134 | val bottomForward = start.copy(y = amplitude).rotateAround(size.center, 360f * ratio) 135 | quadraticTo( 136 | p1 = topForward, 137 | p2 = bottomForward 138 | ) 139 | val p3 = bottomForward.rotateAround(pivot = size.center, degrees = 360f / count / 2f) 140 | val p4 = topForward.rotateAround(pivot = size.center, degrees = 360f / count / 2f) 141 | quadraticTo( 142 | p3, p4 143 | ) 144 | } 145 | close() 146 | } 147 | 148 | private fun Path.setToAccidentalArt1(size: Size) { 149 | val center = size.center 150 | val amplitude = size.height / 10f 151 | val start = Offset(center.x, amplitude / 2f) 152 | moveTo(start) 153 | val count = 60 154 | for (i in 0..count) { 155 | val ratio = i.toFloat() / count 156 | val anglePeriod = 360f / count 157 | val topForward = start.rotateAround(pivot = center, degrees = 360f * ratio) 158 | val bottomForward = start.copy(y = amplitude).rotateAround(size.center, 360f * ratio) 159 | quadraticTo( 160 | p1 = topForward, 161 | p2 = start.rotateAround(center, anglePeriod) 162 | ) 163 | val p3 = bottomForward.rotateAround(pivot = size.center, degrees = 360f / count / 2f) 164 | val p4 = topForward.rotateAround(pivot = size.center, degrees = 360f / count / 2f) 165 | quadraticTo( 166 | p3, p4 167 | ) 168 | } 169 | close() 170 | } 171 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/elements/vectors/wearOsLogo.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.elements.vectors 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import androidx.compose.ui.graphics.SolidColor 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.graphics.StrokeCap 8 | import androidx.compose.ui.graphics.StrokeJoin 9 | import androidx.compose.ui.graphics.vector.ImageVector 10 | import androidx.compose.ui.graphics.PathFillType 11 | import androidx.compose.ui.graphics.vector.VectorPainter 12 | import androidx.compose.ui.graphics.vector.path 13 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 14 | import androidx.compose.ui.unit.dp 15 | 16 | @Composable 17 | fun rememberWearOsLogoVectorPainter(): VectorPainter { 18 | return rememberVectorPainter(remember { wearOsIcon() }) 19 | } 20 | 21 | fun wearOsIcon(): ImageVector = ImageVector.Builder( 22 | name = "WearOsIcon", 23 | defaultWidth = 469.7.dp, 24 | defaultHeight = 359.dp, 25 | viewportWidth = 469.7f, 26 | viewportHeight = 359f 27 | ).apply { 28 | path( 29 | fill = SolidColor(Color(0xFF00A94B)), 30 | fillAlpha = 1.0f, 31 | stroke = null, 32 | strokeAlpha = 1.0f, 33 | strokeLineWidth = 1.0f, 34 | strokeLineCap = StrokeCap.Butt, 35 | strokeLineJoin = StrokeJoin.Miter, 36 | strokeLineMiter = 1.0f, 37 | pathFillType = PathFillType.NonZero 38 | ) { 39 | moveTo(418.59999999999997f, 146.4f) 40 | arcTo( 41 | 48.7f, 42 | 48.7f, 43 | 0f, 44 | isMoreThanHalf = false, 45 | isPositiveArc = true, 46 | 369.9f, 47 | 195.10000000000002f 48 | ) 49 | arcTo(48.7f, 48.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, 321.2f, 146.4f) 50 | arcTo( 51 | 48.7f, 52 | 48.7f, 53 | 0f, 54 | isMoreThanHalf = false, 55 | isPositiveArc = true, 56 | 418.59999999999997f, 57 | 146.4f 58 | ) 59 | close() 60 | } 61 | path( 62 | fill = SolidColor(Color(0xFFFF4131)), 63 | fillAlpha = 1.0f, 64 | stroke = null, 65 | strokeAlpha = 1.0f, 66 | strokeLineWidth = 1.0f, 67 | strokeLineCap = StrokeCap.Butt, 68 | strokeLineJoin = StrokeJoin.Miter, 69 | strokeLineMiter = 1.0f, 70 | pathFillType = PathFillType.NonZero 71 | ) { 72 | moveTo(469.7f, 46.3f) 73 | arcTo(45.5f, 45.5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 424.2f, 91.8f) 74 | arcTo(45.5f, 45.5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 378.7f, 46.3f) 75 | arcTo(45.5f, 45.5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 469.7f, 46.3f) 76 | close() 77 | } 78 | path( 79 | fill = SolidColor(Color(0xFFFFBC00)), 80 | fillAlpha = 1.0f, 81 | stroke = null, 82 | strokeAlpha = 1.0f, 83 | strokeLineWidth = 1.0f, 84 | strokeLineCap = StrokeCap.Butt, 85 | strokeLineJoin = StrokeJoin.Miter, 86 | strokeLineMiter = 1.0f, 87 | pathFillType = PathFillType.NonZero 88 | ) { 89 | moveTo(305.5f, 359f) 90 | curveToRelative(-17.4f, 0f, -34.1f, -10.1f, -41.6f, -27f) 91 | lineTo(144.6f, 64.1f) 92 | curveToRelative(-10.2f, -23f, 0.1f, -49.9f, 23.1f, -60.1f) 93 | curveToRelative(23f, -10.2f, 49.9f, 0.1f, 60.1f, 23.1f) 94 | lineToRelative(119.3f, 267.9f) 95 | curveToRelative(10.2f, 23f, -0.1f, 49.9f, -23.1f, 60.1f) 96 | curveTo(318f, 357.8f, 311.7f, 359f, 305.5f, 359f) 97 | close() 98 | } 99 | path( 100 | fill = SolidColor(Color(0xFF0085F7)), 101 | fillAlpha = 1.0f, 102 | stroke = null, 103 | strokeAlpha = 1.0f, 104 | strokeLineWidth = 1.0f, 105 | strokeLineCap = StrokeCap.Butt, 106 | strokeLineJoin = StrokeJoin.Miter, 107 | strokeLineMiter = 1.0f, 108 | pathFillType = PathFillType.NonZero 109 | ) { 110 | moveTo(164.7f, 358.3f) 111 | curveToRelative(-19f, 0f, -37.1f, -11f, -45.3f, -29.4f) 112 | lineTo(4.3f, 70.3f) 113 | curveTo(-6.8f, 45.3f, 4.4f, 16f, 29.4f, 4.9f) 114 | reflectiveCurveTo(83.7f, 5f, 94.8f, 30f) 115 | lineToRelative(115.1f, 258.6f) 116 | curveToRelative(11.1f, 25f, -0.1f, 54.3f, -25.1f, 65.4f) 117 | curveTo(178.3f, 356.9f, 171.4f, 358.3f, 164.7f, 358.3f) 118 | close() 119 | } 120 | }.build() 121 | 122 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/AndroidTextPaint.kt: -------------------------------------------------------------------------------- 1 | package extensions 2 | 3 | import android.text.TextPaint 4 | import androidx.annotation.VisibleForTesting 5 | import androidx.compose.ui.geometry.Size 6 | import androidx.compose.ui.geometry.isSpecified 7 | import androidx.compose.ui.graphics.BlendMode 8 | import androidx.compose.ui.graphics.Brush 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.graphics.Paint 11 | import androidx.compose.ui.graphics.PaintingStyle 12 | import androidx.compose.ui.graphics.ShaderBrush 13 | import androidx.compose.ui.graphics.Shadow 14 | import androidx.compose.ui.graphics.SolidColor 15 | import androidx.compose.ui.graphics.asComposePaint 16 | import androidx.compose.ui.graphics.drawscope.DrawStyle 17 | import androidx.compose.ui.graphics.drawscope.Fill 18 | import androidx.compose.ui.graphics.drawscope.Stroke 19 | import androidx.compose.ui.graphics.isSpecified 20 | import androidx.compose.ui.graphics.toArgb 21 | import androidx.compose.ui.text.style.TextDecoration 22 | import kotlin.math.roundToInt 23 | 24 | internal class AndroidTextPaint(flags: Int, density: Float) : TextPaint(flags) { 25 | init { 26 | this.density = density 27 | } 28 | 29 | // A wrapper to use Compose Paint APIs on this TextPaint 30 | private val composePaint: Paint = this.asComposePaint() 31 | 32 | private var textDecoration: TextDecoration = TextDecoration.None 33 | 34 | @VisibleForTesting 35 | internal var shadow: Shadow = Shadow.None 36 | 37 | private var drawStyle: DrawStyle? = null 38 | 39 | fun setTextDecoration(textDecoration: TextDecoration?) { 40 | if (textDecoration == null) return 41 | if (this.textDecoration != textDecoration) { 42 | this.textDecoration = textDecoration 43 | isUnderlineText = TextDecoration.Underline in this.textDecoration 44 | isStrikeThruText = TextDecoration.LineThrough in this.textDecoration 45 | } 46 | } 47 | 48 | fun setShadow(shadow: Shadow?) { 49 | if (shadow == null) return 50 | if (this.shadow != shadow) { 51 | this.shadow = shadow 52 | if (this.shadow == Shadow.None) { 53 | clearShadowLayer() 54 | } else { 55 | setShadowLayer( 56 | correctBlurRadius(this.shadow.blurRadius), 57 | this.shadow.offset.x, 58 | this.shadow.offset.y, 59 | this.shadow.color.toArgb() 60 | ) 61 | } 62 | } 63 | } 64 | 65 | fun setColor(color: Color) { 66 | if (color.isSpecified) { 67 | composePaint.color = color 68 | composePaint.shader = null 69 | } 70 | } 71 | 72 | fun setBrush(brush: Brush?, size: Size, alpha: Float = Float.NaN) { 73 | // if size is unspecified and brush is not null, nothing should be done. 74 | // it basically means brush is given but size is not yet calculated at this time. 75 | if ((brush is SolidColor && brush.value.isSpecified) || 76 | (brush is ShaderBrush && size.isSpecified)) { 77 | // alpha is always applied even if Float.NaN is passed to applyTo function. 78 | // if it's actually Float.NaN, we simply send the current value 79 | brush.applyTo( 80 | size, 81 | composePaint, 82 | if (alpha.isNaN()) composePaint.alpha else alpha.coerceIn(0f, 1f) 83 | ) 84 | } else if (brush == null) { 85 | composePaint.shader = null 86 | } 87 | } 88 | 89 | fun setDrawStyle(drawStyle: DrawStyle) { 90 | if (this.drawStyle != drawStyle) { 91 | this.drawStyle = drawStyle 92 | when (drawStyle) { 93 | Fill -> { 94 | // Stroke properties such as strokeWidth, strokeMiter are not re-set because 95 | // Fill style should make those properties no-op. Next time the style is set 96 | // as Stroke, stroke properties get re-set as well. 97 | composePaint.style = PaintingStyle.Fill 98 | } 99 | is Stroke -> { 100 | composePaint.style = PaintingStyle.Stroke 101 | composePaint.strokeWidth = drawStyle.width 102 | composePaint.strokeMiterLimit = drawStyle.miter 103 | composePaint.strokeJoin = drawStyle.join 104 | composePaint.strokeCap = drawStyle.cap 105 | composePaint.pathEffect = drawStyle.pathEffect 106 | } 107 | } 108 | } 109 | } 110 | 111 | // BlendMode is only available to DrawScope.drawText. 112 | // not intended to be used by TextStyle/SpanStyle. 113 | var blendMode: BlendMode by composePaint::blendMode 114 | } 115 | 116 | /** 117 | * Accepts an alpha value in the range [0f, 1f] then maps to an integer value 118 | * in [0, 255] range. 119 | */ 120 | internal fun TextPaint.setAlpha(alpha: Float) { 121 | if (!alpha.isNaN()) { 122 | val alphaInt = alpha.coerceIn(0f, 1f).times(255).roundToInt() 123 | setAlpha(alphaInt) 124 | } 125 | } 126 | 127 | internal fun correctBlurRadius(blurRadius: Float) = if (blurRadius == 0f) { 128 | Float.MIN_VALUE 129 | } else { 130 | blurRadius 131 | } 132 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/Colors.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.extensions 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | fun List.loop(): List = this + this.first() 6 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/DrawScope.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.extensions 2 | 3 | import android.graphics.Paint 4 | import android.graphics.drawable.Drawable 5 | import androidx.annotation.FloatRange 6 | import androidx.compose.ui.geometry.Offset 7 | import androidx.compose.ui.geometry.Size 8 | import androidx.compose.ui.graphics.BlendMode 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.graphics.ColorFilter 11 | import androidx.compose.ui.graphics.DefaultAlpha 12 | import androidx.compose.ui.graphics.Path 13 | import androidx.compose.ui.graphics.asAndroidPath 14 | import androidx.compose.ui.graphics.drawscope.DrawScope 15 | import androidx.compose.ui.graphics.drawscope.DrawStyle 16 | import androidx.compose.ui.graphics.drawscope.Fill 17 | import androidx.compose.ui.graphics.drawscope.translate 18 | import androidx.compose.ui.graphics.drawscope.withTransform 19 | import androidx.compose.ui.graphics.nativeCanvas 20 | import androidx.compose.ui.graphics.painter.Painter 21 | import androidx.compose.ui.text.style.TextAlign 22 | import androidx.compose.ui.unit.LayoutDirection 23 | import androidx.compose.ui.unit.TextUnitType 24 | import androidx.core.graphics.drawable.updateBounds 25 | import extensions.AndroidTextPaint 26 | import extensions.setAlpha 27 | import org.splitties.compose.oclock.sample.extensions.text.TextOnPathLayoutResult 28 | 29 | fun DrawScope.drawOval( 30 | color: Color, 31 | size: Size = this.size, 32 | center: Offset = this.center, 33 | @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f, 34 | style: DrawStyle = Fill, 35 | colorFilter: ColorFilter? = null, 36 | blendMode: BlendMode = DrawScope.DefaultBlendMode 37 | ) = drawOval( 38 | color = color, 39 | topLeft = center.centerAsTopLeft(size), 40 | alpha = alpha, 41 | size = size, 42 | style = style, 43 | colorFilter = colorFilter, 44 | blendMode = blendMode 45 | ) 46 | 47 | fun DrawScope.drawArc( 48 | color: Color, 49 | startAngle: Float, 50 | sweepAngle: Float, 51 | useCenter: Boolean, 52 | size: Size = this.size, 53 | center: Offset = this.center, 54 | @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f, 55 | style: DrawStyle = Fill, 56 | colorFilter: ColorFilter? = null, 57 | blendMode: BlendMode = DrawScope.DefaultBlendMode 58 | ) = drawArc( 59 | color = color, 60 | topLeft = center.centerAsTopLeft(size), 61 | startAngle = startAngle, 62 | sweepAngle = sweepAngle, 63 | useCenter = useCenter, 64 | alpha = alpha, 65 | size = size, 66 | style = style, 67 | colorFilter = colorFilter, 68 | blendMode = blendMode 69 | ) 70 | 71 | fun DrawScope.drawPainter( 72 | painter: Painter, 73 | topLeft: Offset = Offset.Zero, 74 | size: Size = painter.intrinsicSize, 75 | alpha: Float = DefaultAlpha, 76 | colorFilter: ColorFilter? = null 77 | ) { 78 | withTransform({ 79 | translate(topLeft) 80 | }) { 81 | with(painter) { 82 | draw(size, alpha, colorFilter) 83 | } 84 | } 85 | } 86 | 87 | fun DrawScope.drawTextOnPath( 88 | textLayoutResult: TextOnPathLayoutResult, 89 | path: Path, 90 | offset: Offset = Offset.Zero, 91 | @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f, 92 | blendMode: BlendMode = DrawScope.DefaultBlendMode 93 | ) { 94 | drawContext.canvas.nativeCanvas.drawTextOnPath( 95 | textLayoutResult.layoutInput.text.text, 96 | path.asAndroidPath(), 97 | offset.x, 98 | offset.y, 99 | textPaint.also { 100 | it.setFrom( 101 | textLayoutResult = textLayoutResult, 102 | blendMode = blendMode, 103 | alpha = alpha 104 | ) 105 | } 106 | ) 107 | } 108 | 109 | private val textPaint = AndroidTextPaint( 110 | flags = Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG, 111 | density = 1f 112 | ) 113 | 114 | context(DrawScope) 115 | internal fun AndroidTextPaint.setFrom( 116 | textLayoutResult: TextOnPathLayoutResult, 117 | blendMode: BlendMode, 118 | @FloatRange(from = 0.0, to = 1.0) alpha: Float, 119 | ) { 120 | val layoutInput = textLayoutResult.layoutInput 121 | val style = layoutInput.style 122 | this.density = layoutInput.density.density 123 | setColor(style.color) 124 | setBrush(style.brush, size) 125 | setShadow(style.shadow) 126 | setDrawStyle(style.drawStyle ?: Fill) 127 | this.blendMode = blendMode 128 | when (style.fontSize.type) { 129 | TextUnitType.Sp -> textSize = style.fontSize.toPx() 130 | TextUnitType.Em -> { 131 | textSize *= style.fontSize.value 132 | } 133 | else -> {} // Do nothing 134 | } 135 | textAlign = style.textAlign.toPaintAlign(layoutDirection) 136 | setTextDecoration(style.textDecoration) 137 | 138 | setAlpha(alpha) 139 | 140 | // See internal fun AndroidTextPaint.applySpanStyle in TextPaintExtensions.android.kt 141 | typeface = textLayoutResult.typeface 142 | fontFeatureSettings = style.fontFeatureSettings 143 | val textGeometricTransform = style.textGeometricTransform 144 | textScaleX = textGeometricTransform?.scaleX ?: 1f 145 | textSkewX = textGeometricTransform?.skewX ?: 0f 146 | if (style.letterSpacing.type == TextUnitType.Sp && style.letterSpacing.value != 0.0f) { 147 | val emWidth = textSize * textScaleX 148 | val letterSpacingPx = style.letterSpacing.toPx() 149 | // Do nothing if emWidth is 0.0f. 150 | if (emWidth != 0.0f) { 151 | letterSpacing = letterSpacingPx / emWidth 152 | } 153 | } else if (style.letterSpacing.type == TextUnitType.Em) { 154 | letterSpacing = style.letterSpacing.value 155 | } 156 | } 157 | 158 | private fun TextAlign.toPaintAlign(layoutDirection: LayoutDirection): Paint.Align = when (this) { 159 | TextAlign.Left -> Paint.Align.LEFT 160 | TextAlign.Right -> Paint.Align.RIGHT 161 | TextAlign.Center -> Paint.Align.CENTER 162 | TextAlign.Start -> when (layoutDirection) { 163 | LayoutDirection.Ltr -> Paint.Align.LEFT 164 | LayoutDirection.Rtl -> Paint.Align.RIGHT 165 | } 166 | TextAlign.End -> when (layoutDirection) { 167 | LayoutDirection.Ltr -> Paint.Align.RIGHT 168 | LayoutDirection.Rtl -> Paint.Align.LEFT 169 | } 170 | else -> TextAlign.Start.toPaintAlign(layoutDirection) 171 | } 172 | 173 | fun DrawScope.drawDrawable( 174 | drawable: Drawable, 175 | topLeft: Offset = Offset.Zero, 176 | size: Size = this.size 177 | ) { 178 | val left = topLeft.x 179 | val top = topLeft.y 180 | val right = left + size.width 181 | val bottom = top + size.height 182 | drawable.updateBounds( 183 | left = left.toInt(), 184 | top = top.toInt(), 185 | right = right.toInt(), 186 | bottom = bottom.toInt(), 187 | ) 188 | drawable.draw(drawContext.canvas.nativeCanvas) 189 | } 190 | 191 | inline fun DrawScope.translate(offset: Offset, block: DrawScope.() -> Unit) { 192 | translate(left = offset.x, top = offset.y, block = block) 193 | } 194 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/DrawTransform.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("NOTHING_TO_INLINE") 2 | 3 | package org.splitties.compose.oclock.sample.extensions 4 | 5 | import androidx.compose.ui.geometry.Offset 6 | import androidx.compose.ui.graphics.drawscope.DrawTransform 7 | 8 | inline fun DrawTransform.translate(offset: Offset) { 9 | translate(left = offset.x, top = offset.y) 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/GraphicsLayer.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("NOTHING_TO_INLINE") 2 | 3 | package org.splitties.compose.oclock.sample.extensions 4 | 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.graphics.drawscope.DrawScope 8 | import androidx.compose.ui.graphics.layer.GraphicsLayer 9 | import androidx.compose.ui.graphics.layer.drawLayer 10 | import androidx.compose.ui.graphics.rememberGraphicsLayer 11 | import androidx.compose.ui.platform.LocalLayoutDirection 12 | import androidx.compose.ui.unit.Density 13 | import androidx.compose.ui.unit.IntSize 14 | import androidx.compose.ui.unit.LayoutDirection 15 | import androidx.compose.ui.unit.toIntSize 16 | 17 | context(SizeDependentState.Scope) 18 | inline fun GraphicsLayer.centerHorizontally() { 19 | translationX = scope().size.width / 2f - size.width / 2f 20 | } 21 | 22 | context(SizeDependentState.Scope) 23 | inline fun GraphicsLayer.center() { 24 | translationX = scope().size.width / 2f - size.width / 2f 25 | translationY = scope().size.height / 2f - size.height / 2f 26 | } 27 | 28 | @Composable 29 | fun rememberFullSizeRecordedGraphicsLayer( 30 | configureLayer: (GraphicsLayer) -> Unit = {}, 31 | block: DrawScope.() -> Unit 32 | ): SizeDependentState { 33 | return rememberGraphicsLayerAsState { layer -> 34 | configureLayer(layer) 35 | layer.record(size = size.toIntSize()) { 36 | block() 37 | } 38 | } 39 | } 40 | 41 | @Composable 42 | fun rememberGraphicsLayerAsState( 43 | key1: Any? = Unit, 44 | key2: Any? = Unit, 45 | block: context(SimplifiedGraphicsLayerContext) SizeDependentState.Scope.(layer: GraphicsLayer) -> Unit 46 | ): SizeDependentState { 47 | val layoutDir = LocalLayoutDirection.current 48 | val simplifiedGraphicsLayerContext = remember(layoutDir) { 49 | SimplifiedGraphicsLayerContext(layoutDir) 50 | } 51 | return rememberGraphicsLayer().rememberAsStateWithSize(key1, key2, simplifiedGraphicsLayerContext) { 52 | with(simplifiedGraphicsLayerContext) { 53 | block(this@rememberAsStateWithSize, it) 54 | } 55 | } 56 | } 57 | 58 | inline fun DrawScope.drawLayer(layer: SizeDependentState) { 59 | drawLayer(layer.get()) 60 | } 61 | 62 | class SimplifiedGraphicsLayerContext internal constructor(private val layoutDir: LayoutDirection) { 63 | 64 | /** 65 | * Any previously content drawn into a [GraphicsLayer] is dropped at some point 66 | * when it is no longer visible (e.g. when switching to other apps), 67 | * and we need to re-record the content. 68 | * 69 | * To avoid this problem, we re-record every time we become visible again. 70 | */ 71 | context(SizeDependentState.Scope) 72 | fun GraphicsLayer.record( 73 | size: IntSize, 74 | block: DrawScope.() -> Unit 75 | ) { 76 | record( 77 | density = asDensity(), 78 | layoutDirection = layoutDir, 79 | size = size 80 | ) { 81 | block() 82 | } 83 | } 84 | 85 | private fun SizeDependentState.Scope.asDensity(): Density = this 86 | } 87 | 88 | @PublishedApi 89 | internal inline fun SizeDependentState.Scope.scope(): SizeDependentState.Scope = this 90 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/Math.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.extensions 2 | 3 | import kotlin.math.PI 4 | 5 | fun Float.degreesToRadians(): Float = (PI / 180 * this).toFloat() 6 | fun Float.radiansToDegrees(): Float = this * (180 / PI).toFloat() 7 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/Offset.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.extensions 2 | 3 | import androidx.compose.ui.geometry.Offset 4 | import androidx.compose.ui.geometry.Size 5 | import androidx.compose.ui.graphics.drawscope.DrawScope 6 | import androidx.compose.ui.unit.IntSize 7 | import kotlin.math.atan2 8 | import kotlin.math.cos 9 | import kotlin.math.sin 10 | 11 | context (DrawScope) 12 | fun Offset.rotate(degrees: Float): Offset { 13 | return rotateAround(pivot = center, degrees = degrees) 14 | } 15 | 16 | fun Offset.rotateAround( 17 | pivot: Offset, 18 | degrees: Float, 19 | ): Offset { 20 | val angle = Math.toRadians(degrees.toDouble()).toFloat() 21 | val cx = pivot.x 22 | val cy = pivot.y 23 | val offsetX = x - cx // Offset backwards to match the center of the Unit Circle. 24 | val offsetY = y - cy // Offset backwards to match the center of the Unit Circle. 25 | 26 | val offsetRotatedX = offsetX * cos(angle) - offsetY * sin(angle) 27 | val offsetRotatedY = offsetX * sin(angle) + offsetY * cos(angle) 28 | return Offset( 29 | x = offsetRotatedX + cx, // Offset back, forward. 30 | y = offsetRotatedY + cy // Offset back, forward. 31 | ) 32 | } 33 | 34 | fun Offset.centerAsTopLeft(size: Size): Offset { 35 | return copy(x = x - size.width / 2f, y = y - size.height / 2f) 36 | } 37 | 38 | fun Offset.centerAsTopLeft(side: Float): Offset { 39 | return copy(x = x - side / 2f, y = y - side / 2f) 40 | } 41 | 42 | fun Offset.centerAsTopLeft(size: IntSize): Offset { 43 | return copy(x = x - size.width / 2f, y = y - size.height / 2f) 44 | } 45 | 46 | fun Offset.topCenterAsTopLeft(size: Size): Offset { 47 | return copy(x = x - size.width / 2f) 48 | } 49 | 50 | fun Offset.topCenterAsTopLeft(size: IntSize): Offset { 51 | return copy(x = x - size.width / 2f) 52 | } 53 | 54 | fun Offset.topCenterAsTopLeft(width: Float): Offset { 55 | return copy(x = x - width / 2f) 56 | } 57 | 58 | operator fun Offset.plus(amount: Float): Offset = copy(x = x + amount, y = y + amount) 59 | operator fun Offset.plus(size: Size): Offset = copy(x = x + size.width, y = y + size.height) 60 | 61 | operator fun Offset.minus(size: Size): Offset = copy(x = x - size.width, y = y - size.height) 62 | operator fun Offset.minus(amount: Float): Offset = copy(x = x - amount, y = y - amount) 63 | 64 | fun Offset.offsetBy(x: Float = 0f, y: Float = 0f): Offset { 65 | return copy(x = this.x + x, y = this.y + y) 66 | } 67 | 68 | fun Offset.angleTo(other: Offset): Float { 69 | return Math.toDegrees(atan2( 70 | y = y * other.x - x * other.y, 71 | x = x * other.x + y * other.y 72 | ).toDouble()).toFloat() 73 | } 74 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/Paint.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.extensions 2 | 3 | import androidx.compose.ui.graphics.Paint 4 | 5 | fun Paint.setFrom(other: Paint) { 6 | asFrameworkPaint().set(other.asFrameworkPaint()) 7 | } 8 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/Path.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("NOTHING_TO_INLINE") 2 | 3 | package org.splitties.compose.oclock.sample.extensions 4 | 5 | import androidx.compose.ui.geometry.Offset 6 | import androidx.compose.ui.graphics.Path 7 | 8 | inline fun Path.moveTo(position: Offset) { 9 | moveTo(x = position.x, y = position.y) 10 | } 11 | 12 | inline fun Path.lineTo(position: Offset) { 13 | lineTo(x = position.x, y = position.y) 14 | } 15 | 16 | inline fun Path.quadraticTo( 17 | p1: Offset, 18 | p2: Offset 19 | ) = quadraticTo( 20 | x1 = p1.x, 21 | y1 = p1.y, 22 | x2 = p2.x, 23 | y2 = p2.y 24 | ) 25 | 26 | inline fun Path.cubicTo( 27 | cp1: Offset, 28 | cp2: Offset, 29 | point: Offset 30 | ) = cubicTo( 31 | x1 = cp1.x, 32 | y1 = cp1.y, 33 | x2 = cp2.x, 34 | y2 = cp2.y, 35 | x3 = point.x, 36 | y3 = point.y 37 | ) 38 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/PathPattern.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.extensions 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import androidx.compose.ui.geometry.Rect 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.graphics.Path 8 | import androidx.compose.ui.graphics.PathMeasure 9 | import androidx.compose.ui.graphics.drawscope.ContentDrawScope 10 | import androidx.compose.ui.graphics.drawscope.DrawScope 11 | import androidx.compose.ui.graphics.drawscope.DrawStyle 12 | import androidx.compose.ui.graphics.drawscope.Fill 13 | import androidx.compose.ui.graphics.drawscope.Stroke 14 | import androidx.compose.ui.graphics.drawscope.rotate 15 | import androidx.compose.ui.graphics.layer.CompositingStrategy 16 | import androidx.compose.ui.graphics.layer.GraphicsLayer 17 | import androidx.compose.ui.graphics.layer.drawLayer 18 | import androidx.compose.ui.unit.IntSize 19 | import androidx.compose.ui.unit.toIntSize 20 | import org.splitties.compose.oclock.OClockCanvas 21 | import kotlin.math.ceil 22 | import kotlin.math.floor 23 | import kotlin.math.roundToInt 24 | 25 | @Composable 26 | fun PathPattern( 27 | slices: Int = 1, 28 | slicesCompositingStrategy: CompositingStrategy = CompositingStrategy.Offscreen, 29 | getRatio: () -> Float = { 1f }, 30 | getSliceDrawStyle: SizeDependentState.Scope.() -> DrawStyle = { Fill }, 31 | updateSlice: SizeDependentState.Scope.(Path) -> Unit, 32 | drawWireframeSlicePath: (DrawScope.(path: Path) -> Unit)? = null, 33 | drawPath: DrawScope.(path: Path) -> Unit = { drawPath(it, Color.White) }, 34 | onDrawWireframe: ContentDrawScope.(reusedSliceLayer: GraphicsLayer) -> Unit = { drawContent() }, 35 | onDrawWithSliceContent: ContentDrawScope.( 36 | reusedSliceLayer: GraphicsLayer, 37 | index: Int 38 | ) -> Unit = { _, _ -> drawContent() } 39 | ) { 40 | val pathMeasure = remember { PathMeasure() } 41 | val slicePath = remember { Path() }.rememberAsStateWithSize(updateSlice) { 42 | it.reset() 43 | updateSlice(it) 44 | pathMeasure.setPath(it, forceClosed = false) 45 | } 46 | val lastSegmentPath = remember { Path() }.rememberAsStateWithSize(getRatio) { 47 | val slicesInverse = 60f / slices 48 | val ratio = getRatio() * 60 % slicesInverse / slicesInverse 49 | val end = pathMeasure.length * ratio 50 | it.reset() 51 | pathMeasure.getSegment(startDistance = 0f, stopDistance = end, destination = it) 52 | } 53 | val sliceWireframeLayer = rememberGraphicsLayerAsState(drawWireframeSlicePath) { 54 | val path = slicePath.get() 55 | it.compositingStrategy = CompositingStrategy.Offscreen 56 | it.record(size = size.toIntSize()) { 57 | drawWireframeSlicePath?.let { 58 | repeat(slices) { i -> 59 | rotate(360f * i / slices.toFloat()) { 60 | it(path) 61 | } 62 | } 63 | } 64 | } 65 | } 66 | val sliceLayer = rememberGraphicsLayerAsState(getSliceDrawStyle) { 67 | val path = slicePath.get() 68 | it.compositingStrategy = slicesCompositingStrategy 69 | if (slicesCompositingStrategy == CompositingStrategy.Offscreen) { 70 | 71 | val bounds = path.getBounds() 72 | val layerSize = bounds.size 73 | val width = when (val style = getSliceDrawStyle()) { 74 | Fill -> 0f 75 | is Stroke -> style.width 76 | } 77 | val halfW = width / 2f 78 | val offset = bounds.topLeft 79 | val margin = halfW + 1f 80 | it.record(size = (layerSize + margin * 2f).toIntSize()) { 81 | translate(-offset + margin) { 82 | drawPath(path) 83 | } 84 | } 85 | offset.let { (x, y) -> 86 | it.translationX = x - margin 87 | it.translationY = y - margin 88 | } 89 | } else { 90 | it.record(size = size.toIntSize()) { 91 | drawPath(path) 92 | } 93 | } 94 | } 95 | val lastSliceLayer = rememberGraphicsLayerAsState { 96 | it.compositingStrategy = CompositingStrategy.Offscreen 97 | val path = lastSegmentPath.get() 98 | val bounds = path.getBounds() 99 | val layerSize = bounds.size 100 | val width = when (val style = getSliceDrawStyle()) { 101 | Fill -> 0f 102 | is Stroke -> style.width 103 | } 104 | val halfW = width / 2f 105 | val offset = bounds.topLeft 106 | val margin = halfW + 1f 107 | it.record(size = (layerSize + margin * 2f).toIntSize()) { 108 | translate(-offset + margin) { 109 | drawPath(path) 110 | } 111 | } 112 | offset.let { (x, y) -> 113 | it.translationX = x - margin 114 | it.translationY = y - margin 115 | } 116 | } 117 | val layerScopeFactory = remember { LayerDrawScopeFactory() } 118 | OClockCanvas { 119 | val layerDrawScope = layerScopeFactory.getLayerDrawScope(this) 120 | if (drawWireframeSlicePath != null) layerDrawScope.withLayer(sliceWireframeLayer.get()) { 121 | onDrawWireframe(it) 122 | } 123 | val ratio = getRatio() 124 | val last = floor(ratio * slices).roundToInt() 125 | for (i in 0.. Unit) { 154 | this.layer = layer 155 | try { 156 | block(layer) 157 | } finally { 158 | this.layer = null 159 | } 160 | } 161 | 162 | private var layer: GraphicsLayer? = null 163 | override fun drawContent() { 164 | drawLayer(layer ?: return) 165 | } 166 | } 167 | 168 | private fun Rect.toIntSize(): IntSize = IntSize( 169 | width = ceil(width).toInt(), 170 | height = ceil(height).toInt() 171 | ) 172 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/PatternsMath.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.extensions 2 | 3 | import kotlin.math.PI 4 | import kotlin.math.sin 5 | 6 | fun circleDiameterInCircularPattern( 7 | outerCircle: Float, 8 | n: Int 9 | ): Float { 10 | val a = sin(PI / n).toFloat() 11 | return (a * outerCircle) / (1 + a) 12 | } 13 | 14 | fun circleRadiusInCircularPattern( 15 | outerCircle: Float, 16 | n: Int 17 | ): Float = circleDiameterInCircularPattern(outerCircle, n) / 2f 18 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/Size.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.extensions 2 | 3 | import androidx.compose.ui.geometry.Size 4 | import androidx.compose.ui.unit.IntSize 5 | import kotlin.math.floor 6 | import kotlin.math.min 7 | 8 | 9 | operator fun Size.minus(pixels: Float): Size { 10 | return Size(width - pixels, height - pixels) 11 | } 12 | 13 | operator fun Size.plus(pixels: Float): Size { 14 | return Size(width + pixels, height + pixels) 15 | } 16 | 17 | fun Size.Companion.square(pixels: Float): Size { 18 | return Size(pixels, pixels) 19 | } 20 | 21 | /** 22 | * Returns the largest Size that would fit into [other], while keeping the aspect ratio. 23 | */ 24 | fun Size.fitIn(other: Size): Size { 25 | val maxFactor = other.maxDimension / maxDimension 26 | val minFactor = other.minDimension / minDimension 27 | val factor = min(maxFactor, minFactor) 28 | return this * factor 29 | } 30 | 31 | fun Size.toFlooredIntSize(): IntSize { 32 | return IntSize(floor(width).toInt(), floor(height).toInt()) 33 | } 34 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/SizeDependentState.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.extensions 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.SnapshotMutationPolicy 5 | import androidx.compose.runtime.Stable 6 | import androidx.compose.runtime.cache 7 | import androidx.compose.runtime.currentComposer 8 | import androidx.compose.runtime.derivedStateOf 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableFloatStateOf 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.neverEqualPolicy 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.runtime.setValue 15 | import androidx.compose.runtime.structuralEqualityPolicy 16 | import androidx.compose.ui.geometry.Offset 17 | import androidx.compose.ui.geometry.Size 18 | import androidx.compose.ui.geometry.center 19 | import androidx.compose.ui.graphics.drawscope.DrawScope 20 | import androidx.compose.ui.unit.Density 21 | import kotlin.reflect.KProperty 22 | 23 | @Composable fun T.rememberAsStateWithSize( 24 | key1: Any? = Unit, 25 | key2: Any? = Unit, 26 | key3: Any? = Unit, 27 | calculation: SizeDependentState.Scope.(T) -> Unit 28 | ): SizeDependentState = currentComposer.let { 29 | val t = this 30 | it.cache( 31 | it.changed(value = key1) or 32 | it.changed(value = key2) or 33 | it.changed(value = key3) or 34 | it.changed(value = calculation) 35 | ) { 36 | SizeDependentState( 37 | calculation = { calculation(t);t }, 38 | policy = neverEqualPolicy() 39 | ) 40 | } 41 | } 42 | 43 | @Composable 44 | fun rememberStateWithSize( 45 | calculation: SizeDependentState.Scope.() -> T 46 | ): SizeDependentState = remember(calculation) { 47 | SizeDependentState(calculation) 48 | } 49 | 50 | @Composable 51 | fun rememberStateWithSize( 52 | key1: Any?, 53 | calculation: SizeDependentState.Scope.() -> T 54 | ): SizeDependentState = remember( 55 | key1 = key1, 56 | key2 = calculation 57 | ) { 58 | SizeDependentState(calculation) 59 | } 60 | 61 | @Composable 62 | fun rememberStateWithSize( 63 | key1: Any?, 64 | key2: Any?, 65 | calculation: SizeDependentState.Scope.() -> T 66 | ): SizeDependentState = remember( 67 | key1 = key1, 68 | key2 = key2, 69 | key3 = calculation 70 | ) { 71 | SizeDependentState(calculation) 72 | } 73 | 74 | @Composable 75 | fun rememberStateWithSize( 76 | vararg keys: Any?, 77 | calculation: SizeDependentState.Scope.() -> T 78 | ): SizeDependentState = remember(*keys, calculation) { 79 | SizeDependentState(calculation) 80 | } 81 | 82 | @Stable 83 | class SizeDependentState( 84 | private val calculation: Scope.() -> T, 85 | policy: SnapshotMutationPolicy = structuralEqualityPolicy() 86 | ) { 87 | 88 | interface Scope : Density { 89 | val size: Size 90 | val center: Offset 91 | } 92 | 93 | context (Scope) 94 | operator fun getValue(thisRef: Nothing?, property: KProperty<*>?): T = get() 95 | 96 | context (DrawScope) 97 | operator fun getValue(thisRef: Nothing?, property: KProperty<*>?): T = get() 98 | 99 | context (DrawScope, Scope) 100 | operator fun getValue(thisRef: Nothing?, property: KProperty<*>?): T = get() 101 | 102 | context (Scope) 103 | fun provideDelegate(thisRef: Nothing?, property: KProperty<*>?): SizeDependentState { 104 | return this 105 | } 106 | 107 | context (DrawScope) 108 | fun provideDelegate(thisRef: Nothing?, property: KProperty<*>?): SizeDependentState { 109 | get() // Ensure it's activated if declared, in case side-effects are needed at draw time. 110 | return this 111 | } 112 | 113 | context (DrawScope, Scope) 114 | fun provideDelegate(thisRef: Nothing?, property: KProperty<*>?): SizeDependentState { 115 | get() // Ensure it's activated if declared, in case side-effects are needed at draw time. 116 | return this 117 | } 118 | 119 | context (DrawScope) 120 | fun pro(thisRef: Nothing?, property: KProperty<*>): T = get() 121 | 122 | context (Scope) 123 | fun get(): T = get(size, density, fontScale) 124 | 125 | context (DrawScope) 126 | private fun getDrawScopeOnly(): T = get(size, density, fontScale) 127 | 128 | context (DrawScope) 129 | fun get(): T = get(size, density, fontScale) 130 | 131 | context (DrawScope, Scope) 132 | fun get(): T = getDrawScopeOnly() 133 | 134 | private fun get(size: Size, density: Float, fontScale: Float): T { 135 | scope.also { 136 | it.size = size 137 | it.density = density 138 | it.fontScale = fontScale 139 | } 140 | return value 141 | } 142 | 143 | private val value by derivedStateOf(policy = policy) { calculation(scope) } 144 | 145 | private val scope = object : Scope { 146 | override var density: Float by mutableFloatStateOf(1f) 147 | override var fontScale: Float by mutableFloatStateOf(1f) 148 | override var size: Size by mutableStateOf(Size.Unspecified) 149 | override val center: Offset get() = size.center 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/TextLayoutResult.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.extensions 2 | 3 | import androidx.compose.ui.geometry.Offset 4 | import androidx.compose.ui.text.TextLayoutResult 5 | import androidx.compose.ui.unit.IntSize 6 | import androidx.compose.ui.unit.toIntSize 7 | import androidx.compose.ui.unit.toSize 8 | 9 | fun TextLayoutResult.sizeForLayer(): IntSize { 10 | val spaceForBlur = (layoutInput.style.shadow?.blurRadius ?: 0f) * 2 11 | return (size.toSize() + spaceForBlur).toIntSize() 12 | } 13 | 14 | fun TextLayoutResult.blurOffset(): Offset = layoutInput.style.shadow?.blurRadius?.let { 15 | Offset(it, it) 16 | } ?: Offset.Zero 17 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/text/TextOnPathLayoutResult.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.extensions.text 2 | 3 | import android.graphics.Typeface 4 | import androidx.compose.runtime.State 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.ui.text.TextLayoutInput 7 | import androidx.compose.ui.text.TextLayoutResult 8 | 9 | class TextOnPathLayoutResult( 10 | val internalResult: TextLayoutResult, 11 | typeFaceState: State 12 | ) { 13 | val layoutInput: TextLayoutInput get() = internalResult.layoutInput 14 | val typeface by typeFaceState 15 | } 16 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/extensions/text/TextOnPathMeasurerHelper.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.extensions.text 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import androidx.compose.ui.platform.LocalDensity 6 | import androidx.compose.ui.platform.LocalFontFamilyResolver 7 | import androidx.compose.ui.platform.LocalLayoutDirection 8 | import androidx.compose.ui.text.TextMeasurer 9 | 10 | /** 11 | * This value should reflect the default cache size for TextMeasurer. 12 | */ 13 | private val DefaultCacheSize: Int = 8 14 | 15 | /** 16 | * Creates and remembers a [TextMeasurer]. All parameters that are required for [TextMeasurer] 17 | * except [cacheSize] are read from CompositionLocals. Created [TextMeasurer] carries an internal 18 | * [androidx.compose.ui.text.TextLayoutCache] with [cacheSize] capacity. Provide 0 for [cacheSize] to opt-out from internal 19 | * caching behavior. 20 | * 21 | * @param cacheSize Capacity of internal cache inside [TextMeasurer]. Size unit is the number of 22 | * unique text layout inputs that are measured. Value of this parameter highly depends on the 23 | * consumer use case. Provide a cache size that is in line with how many distinct text layouts are 24 | * going to be calculated by this measurer repeatedly. If you are animating font attributes, or any 25 | * other layout affecting input, cache can be skipped because most repeated measure calls would miss 26 | * the cache. 27 | */ 28 | @Composable 29 | fun rememberTextOnPathMeasurer( 30 | cacheSize: Int = DefaultCacheSize 31 | ): TextOnPathMeasurer { 32 | val fontFamilyResolver = LocalFontFamilyResolver.current 33 | val density = LocalDensity.current 34 | val layoutDirection = LocalLayoutDirection.current 35 | 36 | return remember(fontFamilyResolver, density, layoutDirection, cacheSize) { 37 | TextOnPathMeasurer(fontFamilyResolver, density, layoutDirection, cacheSize) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/utils/Bits.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.utils 2 | 3 | @PublishedApi 4 | internal fun Int.getBitAt(position: Int, last: Int = 32): Boolean = this shr (last - position) and 1 > 0 5 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/utils/FiveMinutesLayoutOrder.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.utils 2 | 3 | import androidx.annotation.IntRange 4 | 5 | @JvmInline 6 | value class FiveMinutesLayoutOrder( 7 | private val storage: Int 8 | ) { 9 | init { 10 | require((storage and 0b11111_00000_00000_00000).countOneBits() == 1) 11 | require((storage and 0b00000_11111_00000_00000).countOneBits() == 2) 12 | require((storage and 0b00000_00000_11111_00000).countOneBits() == 3) 13 | require((storage and 0b00000_00000_00000_11111).countOneBits() == 4) 14 | } 15 | 16 | companion object { 17 | val linear = FiveMinutesLayoutOrder(0b10000_11000_11100_11110) 18 | val symmetricalSpread = FiveMinutesLayoutOrder(0b00100_01010_10101_11011) 19 | val symmetricalPacked = FiveMinutesLayoutOrder(0b00100_01010_01110_11011) 20 | val symmetricalEdges = FiveMinutesLayoutOrder(0b00100_10001_10101_11011) 21 | } 22 | 23 | fun showFor( 24 | @IntRange(0, 59) minute: Int, 25 | @IntRange(0, 4) minuteMarkIndex: Int, 26 | ): Boolean { 27 | require(minute in 0..59) 28 | require(minuteMarkIndex in 0..4) 29 | return storage.getBitAt( 30 | position = ((minute - 1) % 5) * 5 + minuteMarkIndex, 31 | last = 19 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/utils/FiveMinutesSlicePattern.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.utils 2 | 3 | import androidx.annotation.IntRange 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.geometry.Rect 6 | import androidx.compose.ui.graphics.Path 7 | import androidx.compose.ui.graphics.drawscope.DrawScope 8 | import androidx.compose.ui.graphics.drawscope.rotate 9 | import androidx.compose.ui.graphics.drawscope.scale 10 | import androidx.compose.ui.graphics.layer.CompositingStrategy 11 | import androidx.compose.ui.graphics.layer.drawLayer 12 | import org.splitties.compose.oclock.LocalTime 13 | import org.splitties.compose.oclock.OClockCanvas 14 | import org.splitties.compose.oclock.sample.extensions.SizeDependentState 15 | import org.splitties.compose.oclock.sample.extensions.rememberGraphicsLayerAsState 16 | import org.splitties.compose.oclock.sample.extensions.rememberStateWithSize 17 | import org.splitties.compose.oclock.sample.extensions.toFlooredIntSize 18 | 19 | @Composable 20 | fun FiveMinutesSlicePattern( 21 | layoutOrder: FiveMinutesLayoutOrder = FiveMinutesLayoutOrder.symmetricalSpread, 22 | mirrored: Boolean = true, 23 | createSliceData: SizeDependentState.Scope.(slicePath: Path, bounds: Rect) -> T, 24 | drawSliceContent: DrawScope.(fiveMinutesIndex: Int, sliceData: T) -> Unit 25 | ) { 26 | val slicePathAndBounds = rememberStateWithSize { 27 | val path = Path().also { 28 | it.setToPieSlice(size, 30f, centered = false) 29 | } 30 | path to path.getBounds() 31 | } 32 | val sliceData = rememberStateWithSize { 33 | val (path, bounds) = slicePathAndBounds.get() 34 | createSliceData(path, bounds) 35 | } 36 | val minuteLayers = List(5) { i -> 37 | minuteSliceLayer( 38 | slicePath = slicePathAndBounds, 39 | sliceData = sliceData, 40 | fiveMinutesIndex = i, 41 | drawSliceContent = drawSliceContent 42 | ) 43 | } 44 | val time = LocalTime.current 45 | OClockCanvas { 46 | val max = 12 47 | val minutes = time.minutes 48 | for (i in 0.. minuteSliceLayer( 79 | slicePath: SizeDependentState>, 80 | sliceData: SizeDependentState, 81 | @IntRange(0, 4) fiveMinutesIndex: Int, 82 | drawSliceContent: DrawScope.(fiveMinutesIndex: Int, sliceData: T) -> Unit 83 | ) = rememberGraphicsLayerAsState { layer -> 84 | require(fiveMinutesIndex in 0..4) 85 | val (path, bounds) = slicePath.get() 86 | layer.compositingStrategy = CompositingStrategy.Offscreen 87 | layer.setPathOutline(path) 88 | layer.clip = true 89 | layer.translationX = center.x 90 | @Suppress("name_shadowing") val sliceData = sliceData.get() 91 | layer.record(size = bounds.size.toFlooredIntSize()) { 92 | drawSliceContent(fiveMinutesIndex, sliceData) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/utils/PieSlicePath.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.utils 2 | 3 | import androidx.compose.ui.geometry.Offset 4 | import androidx.compose.ui.geometry.Size 5 | import androidx.compose.ui.geometry.center 6 | import androidx.compose.ui.geometry.toRect 7 | import androidx.compose.ui.graphics.Path 8 | import org.splitties.compose.oclock.sample.extensions.lineTo 9 | import org.splitties.compose.oclock.sample.extensions.moveTo 10 | 11 | fun Path.setToPieSlice(size: Size, degrees: Float, centered: Boolean = true) { 12 | reset() 13 | moveTo(size.center) 14 | lineTo(size.center.copy(y = 0f)) 15 | arcTo(size.toRect(), startAngleDegrees = -90f, sweepAngleDegrees = degrees, forceMoveTo = false) 16 | lineTo(size.center) 17 | close() 18 | if (centered.not()) translate(Offset(x = -size.center.x, y = 0f)) 19 | } 20 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/watchfaces/AllWatchFaces.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.watchfaces 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | val allWatchFaces: List<@Composable () -> Unit> = listOf( 6 | // { LoopingSeconds() }, 7 | // { LightLinesWatchFace() }, 8 | { KotlinFanClock() }, 9 | { ComposeFanClock() }, 10 | { BasicAnalogClock() }, 11 | ) 12 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/watchfaces/BasicAnalogClock.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.watchfaces 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.remember 6 | import androidx.compose.ui.graphics.BlendMode 7 | import androidx.compose.ui.graphics.Brush 8 | import androidx.compose.ui.graphics.Color 9 | import androidx.compose.ui.graphics.SolidColor 10 | import androidx.compose.ui.graphics.StrokeCap 11 | import androidx.compose.ui.graphics.drawscope.Fill 12 | import androidx.compose.ui.graphics.drawscope.Stroke 13 | import androidx.compose.ui.graphics.drawscope.rotate 14 | import androidx.compose.ui.tooling.preview.PreviewParameter 15 | import androidx.compose.ui.unit.Dp 16 | import androidx.compose.ui.unit.dp 17 | import org.splitties.compose.oclock.LocalIsAmbient 18 | import org.splitties.compose.oclock.LocalTime 19 | import org.splitties.compose.oclock.OClockCanvas 20 | import org.splitties.compose.oclock.sample.ComposeOClockWatermark 21 | import org.splitties.compose.oclock.sample.WatchFacePreview 22 | import org.splitties.compose.oclock.sample.WearPreviewSizes 23 | import org.splitties.compose.oclock.sample.elements.clockHand 24 | import org.splitties.compose.oclock.sample.extensions.rotate 25 | 26 | @Composable 27 | fun BasicAnalogClock() { 28 | Background() 29 | val textBrush = remember { 30 | Brush.sweepGradient( 31 | 4.5f/8f to Color(0x7C00E5FF), 32 | 5.1f/8f to Color(0xFF00E5FF), 33 | 6.2f/8f to Color(0xFF00E5FF), 34 | 6.5f/8f to Color(0xFFFFFF8D), 35 | ) 36 | } 37 | ComposeOClockWatermark(textBrush) 38 | HoursHand() 39 | MinutesHand() 40 | SecondsHand() 41 | CenterDot() 42 | } 43 | 44 | @Composable 45 | private fun Background() { 46 | val isAmbient by LocalIsAmbient.current 47 | OClockCanvas { 48 | if (isAmbient.not()) drawCircle(kotlinDarkBg) 49 | } 50 | } 51 | 52 | @Composable 53 | private fun HoursHand() { 54 | val time = LocalTime.current 55 | val isAmbient by LocalIsAmbient.current 56 | OClockCanvas { 57 | rotate(degrees = time.hourWithMinutes * 30) { 58 | val width = 10.dp.toPx() 59 | clockHand( 60 | brush = SolidColor(kotlinLogoColors[0]), 61 | width = width, 62 | height = size.height / 4f, 63 | style = if (isAmbient) Stroke(width = 3.dp.toPx()) else Fill, 64 | blendMode = BlendMode.Plus 65 | ) 66 | } 67 | } 68 | } 69 | 70 | @Composable 71 | private fun MinutesHand() { 72 | val time = LocalTime.current 73 | val isAmbient by LocalIsAmbient.current 74 | OClockCanvas { 75 | rotate(degrees = time.minutes * 6f) { 76 | val width = 10.dp.toPx() 77 | clockHand( 78 | brush = SolidColor(kotlinLogoColors[1]), 79 | width = width, 80 | height = size.height * 3 / 8f, 81 | style = if (isAmbient) Stroke(width = 3.dp.toPx()) else Fill, 82 | blendMode = BlendMode.Plus 83 | ) 84 | } 85 | } 86 | } 87 | 88 | @Composable 89 | private fun SecondsHand() { 90 | val time = LocalTime.current 91 | val isAmbient by LocalIsAmbient.current 92 | OClockCanvas { 93 | if (isAmbient) return@OClockCanvas 94 | val color = kotlinLogoColors[2] 95 | drawLine( 96 | color, 97 | start = center, 98 | end = center.copy(y = size.height / 32f).rotate(time.seconds * 6f), 99 | strokeWidth = 2.dp.toPx(), 100 | blendMode = BlendMode.Lighten, 101 | cap = StrokeCap.Round 102 | ) 103 | } 104 | } 105 | 106 | @Composable 107 | private fun CenterDot() { 108 | val isAmbient by LocalIsAmbient.current 109 | OClockCanvas { 110 | drawCircle(kotlinDarkBg, radius = 6.dp.toPx(), blendMode = BlendMode.Xor) 111 | drawCircle( 112 | color = if (isAmbient) Color.Black else kotlinLogoColors[2], 113 | radius = 5.dp.toPx() 114 | ) 115 | if (isAmbient) { 116 | drawCircle( 117 | Color.Gray, 118 | radius = 5.dp.toPx() 119 | ) 120 | drawCircle( 121 | Color.Black, 122 | radius = 2.dp.toPx() 123 | ) 124 | } 125 | } 126 | } 127 | 128 | private val kotlinDarkBg = Color(0xFF1B1B1B) 129 | private val kotlinBlue = Color(0xFF7F52FF) 130 | private val kotlinLogoColors = listOf( 131 | kotlinBlue, 132 | Color(0xFF_C811E2), 133 | Color(0xFF_E54857), 134 | ) 135 | 136 | @WatchFacePreview 137 | @Composable 138 | private fun BasicAnalogClockPreview( 139 | @PreviewParameter(WearPreviewSizes::class) size: Dp 140 | ) = WatchFacePreview(size) { 141 | BasicAnalogClock() 142 | } 143 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/watchfaces/LightLinesWatchFace.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.watchfaces 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import androidx.compose.ui.geometry.Offset 6 | import androidx.compose.ui.geometry.Size 7 | import androidx.compose.ui.graphics.BlendMode 8 | import androidx.compose.ui.graphics.Brush 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.graphics.drawscope.rotate 11 | import androidx.compose.ui.graphics.drawscope.withTransform 12 | import androidx.compose.ui.graphics.layer.CompositingStrategy 13 | import androidx.compose.ui.graphics.layer.drawLayer 14 | import androidx.compose.ui.unit.Dp 15 | import androidx.compose.ui.unit.dp 16 | import androidx.compose.ui.unit.toIntSize 17 | import androidx.compose.ui.util.lerp 18 | import org.splitties.compose.oclock.OClockCanvas 19 | import org.splitties.compose.oclock.sample.elements.FatHourDigits 20 | import org.splitties.compose.oclock.sample.extensions.angleTo 21 | import org.splitties.compose.oclock.sample.extensions.rememberGraphicsLayerAsState 22 | import org.splitties.compose.oclock.sample.extensions.rotateAround 23 | import org.splitties.compose.oclock.sample.utils.FiveMinutesLayoutOrder 24 | import org.splitties.compose.oclock.sample.utils.FiveMinutesSlicePattern 25 | 26 | @Composable 27 | fun LightLinesWatchFace() { 28 | OClockCanvas { 29 | drawRect(Color.Black) 30 | } 31 | val blue = Color.hsl(180f, 1f, .9f) 32 | val yellow = Color(0xFFF5D370) 33 | LightLinesMinutes(blue, cutAngle = 53f) 34 | FatHourDigits(interactiveColor = blue, ambientShadowRepeat = 2) 35 | } 36 | 37 | @Composable 38 | fun LightLinesMinutes( 39 | color: Color = Color.White, 40 | layoutOrder: FiveMinutesLayoutOrder = FiveMinutesLayoutOrder.symmetricalSpread, 41 | lineStartTopPadding: Dp = 8.dp, 42 | lineStartBottomPadding: Dp = 74.dp, 43 | cutAngle: Float = -1f, 44 | mirrored: Boolean = true 45 | ) { 46 | val brush = remember { lightBrush(color) } 47 | val horizontalLine = rememberGraphicsLayerAsState { 48 | it.compositingStrategy = CompositingStrategy.Offscreen 49 | it.record(Size(size.width * 1.3f, 8.dp.toPx()).toIntSize()) { 50 | drawRect(brush) 51 | } 52 | } 53 | FiveMinutesSlicePattern( 54 | layoutOrder = layoutOrder, 55 | mirrored = mirrored, 56 | createSliceData = { slicePath, bounds -> 57 | val topLimit = lineStartTopPadding.toPx() 58 | val bottomLimit = bounds.height - lineStartBottomPadding.toPx() 59 | val band = bottomLimit - topLimit 60 | val middlePoint = Offset(x = 0f, y = topLimit + band / 2f) 61 | val sliceTip = Offset(x = 0f, y = bounds.height) 62 | val topLeftCorner = Offset.Zero 63 | val topRightCorner = topLeftCorner.rotateAround(sliceTip, degrees = 30f) 64 | val vector = topRightCorner - middlePoint 65 | SliceData( 66 | topLimit = topLimit, 67 | bottomLimit = bottomLimit, 68 | lineAngle = vector.angleTo(Offset(x = 1f, y = 0f)) 69 | ) 70 | }, 71 | drawSliceContent = { i, sliceData -> 72 | val max = 5 73 | val startingPoint = lerp( 74 | start = sliceData.topLimit, 75 | stop = sliceData.bottomLimit, 76 | fraction = i.toFloat() / (max - 1) 77 | ) 78 | val lineLayer = horizontalLine.get() 79 | withTransform({ 80 | translate(top = startingPoint - lineLayer.size.height / 2f) 81 | rotate(sliceData.lineAngle, pivot = Offset.Zero) 82 | translate(left = -lineLayer.size.height.toFloat()) 83 | }) { 84 | drawLayer(lineLayer) 85 | } 86 | if (cutAngle != -1f) rotate( 87 | degrees = sliceData.lineAngle + cutAngle, pivot = Offset.Zero 88 | ) { 89 | drawRect( 90 | Color.Red, 91 | size = Size(width = size.height, 40.dp.toPx()), 92 | blendMode = BlendMode.Clear 93 | ) 94 | } 95 | } 96 | ) 97 | } 98 | 99 | private fun lightBrush(color: Color = Color.White): Brush { 100 | val transparent = color.copy(alpha = 0f) 101 | val mid = color.copy(alpha = .5f) 102 | return Brush.verticalGradient( 103 | 0f to transparent, 104 | .45f to mid, 105 | .45f to color, 106 | .5f to color, 107 | .55f to color, 108 | .55f to mid, 109 | 1f to transparent 110 | ) 111 | } 112 | 113 | private class SliceData( 114 | val topLimit: Float, 115 | val bottomLimit: Float, 116 | val lineAngle: Float 117 | ) 118 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/watchfaces/WatchFaceSwitcher.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.watchfaces 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableIntStateOf 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.runtime.setValue 8 | import androidx.compose.ui.tooling.preview.PreviewParameter 9 | import androidx.compose.ui.unit.Dp 10 | import androidx.compose.ui.unit.dp 11 | import org.splitties.compose.oclock.OClockCanvas 12 | import org.splitties.compose.oclock.TapEvent 13 | import org.splitties.compose.oclock.sample.WatchFacePreview 14 | import org.splitties.compose.oclock.sample.WearPreviewSizes 15 | 16 | @Composable 17 | fun WatchFaceSwitcher() { 18 | var index by remember { mutableIntStateOf(0) } 19 | OClockCanvas( 20 | onTap = handler@{ event -> 21 | val touchEdgeWidth = 48.dp.toPx() 22 | val isOnLeftEdge = event.position.x <= touchEdgeWidth 23 | val isOnRightEdge = isOnLeftEdge.not() && 24 | event.position.x >= size.width - touchEdgeWidth 25 | if (isOnLeftEdge.not() && isOnRightEdge.not()) return@handler false 26 | if (event is TapEvent.Up) { 27 | val lastIndex = allWatchFaces.lastIndex 28 | index = when { 29 | isOnLeftEdge -> index - 1 30 | else -> index + 1 31 | }.let { 32 | when { 33 | it < 0 -> lastIndex 34 | it > lastIndex -> 0 35 | else -> it 36 | } 37 | } 38 | } 39 | true 40 | } 41 | ) { } 42 | allWatchFaces[index]() 43 | } 44 | 45 | @WatchFacePreview 46 | @Composable 47 | private fun WatchFaceSwitcherPreview( 48 | @PreviewParameter(WearPreviewSizes::class) size: Dp 49 | ) = WatchFacePreview(size) { 50 | WatchFaceSwitcher() 51 | } 52 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/watchfaces/hansie/HansieClock.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.watchfaces.hansie 2 | 3 | import android.icu.text.MessageFormat 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.derivedStateOf 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.geometry.Offset 9 | import androidx.compose.ui.graphics.BlendMode 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.graphics.SolidColor 12 | import androidx.compose.ui.graphics.drawscope.rotate 13 | import androidx.compose.ui.platform.LocalConfiguration 14 | import androidx.compose.ui.text.TextStyle 15 | import androidx.compose.ui.text.drawText 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.text.style.TextGeometricTransform 18 | import androidx.compose.ui.unit.dp 19 | import androidx.compose.ui.unit.sp 20 | import org.splitties.compose.oclock.CurrentTime 21 | import org.splitties.compose.oclock.LocalIsAmbient 22 | import org.splitties.compose.oclock.LocalTextMeasurerWithoutCache 23 | import org.splitties.compose.oclock.LocalTime 24 | import org.splitties.compose.oclock.OClockCanvas 25 | import org.splitties.compose.oclock.internal.InternalComposeOClockApi 26 | import org.splitties.compose.oclock.sample.WatchFacePreview 27 | import java.util.Locale 28 | 29 | @Suppress("DEPRECATION") 30 | @Composable 31 | fun HansieClock(locale: Locale = LocalConfiguration.current.locale) { 32 | val isAmbient by LocalIsAmbient.current 33 | HansieBackground() 34 | if (isAmbient.not()) { 35 | HansieSecondsHand(locale) 36 | } 37 | HansieMinutesHand(locale) 38 | HansieHoursHand(locale) 39 | HansieCenterDot() 40 | } 41 | 42 | @Composable 43 | private fun HansieBackground() { 44 | val isAmbient by LocalIsAmbient.current 45 | OClockCanvas { 46 | if (isAmbient.not()) drawCircle(kotlinDarkBg) 47 | } 48 | } 49 | 50 | @Composable 51 | private fun HansieHoursHand(locale: Locale) { 52 | val style = remember { 53 | TextStyle( 54 | fontSize = 26.sp, 55 | fontWeight = FontWeight.W600, 56 | color = Color.White 57 | ) 58 | } 59 | HansieHand(locale, style, HansieHandType.Hour) 60 | } 61 | 62 | @Composable 63 | private fun HansieMinutesHand(locale: Locale) { 64 | val style = remember { 65 | TextStyle( 66 | fontSize = 26.sp, 67 | fontWeight = FontWeight.W400, 68 | color = Color.DarkGray.copy(alpha = 0.8f), 69 | textGeometricTransform = TextGeometricTransform( 70 | scaleX = 1.25f, 71 | skewX = Float.MIN_VALUE 72 | ), 73 | ) 74 | } 75 | 76 | HansieHand(locale, style, HansieHandType.Minute) 77 | } 78 | 79 | @Composable 80 | private fun HansieSecondsHand(locale: Locale) { 81 | val style = remember { 82 | TextStyle( 83 | fontSize = 14.sp, 84 | fontWeight = FontWeight.W200, 85 | color = Color.LightGray.copy(alpha = 0.6f), 86 | textGeometricTransform = TextGeometricTransform( 87 | scaleX = 1.50f, 88 | skewX = Float.MIN_VALUE 89 | ), 90 | ) 91 | } 92 | 93 | HansieHand(locale, style, HansieHandType.Second) 94 | } 95 | 96 | @Composable 97 | private fun HansieHand( 98 | locale: Locale, 99 | style: TextStyle, 100 | type: HansieHandType 101 | ) { 102 | val time = LocalTime.current 103 | 104 | val timeFormatter = remember(locale) { 105 | MessageFormat( 106 | "{0,spellout,full}", 107 | locale 108 | ) 109 | } 110 | @OptIn(InternalComposeOClockApi::class) 111 | val measurer = LocalTextMeasurerWithoutCache.current 112 | 113 | val textLayoutResult by remember(timeFormatter) { 114 | derivedStateOf { 115 | val handValueInt = type.handValue(time).toInt() 116 | val timeText = timeFormatter.format(arrayOf(handValueInt)) 117 | measurer.measure( 118 | timeText, 119 | style 120 | ) 121 | } 122 | } 123 | 124 | OClockCanvas { 125 | val handValue = type.handValue(time) 126 | val degrees = (handValue * type.factor) - 90f 127 | rotate(degrees = degrees) { 128 | drawText( 129 | brush = style.brush ?: SolidColor(style.color), 130 | textLayoutResult = textLayoutResult, 131 | topLeft = center.plus(Offset(25f, -textLayoutResult.size.height / 2f)) 132 | ) 133 | } 134 | } 135 | } 136 | 137 | enum class HansieHandType(val factor: Float) { 138 | Hour(30f) { 139 | override fun handValue(current: CurrentTime): Float = current.hours.toFloat() 140 | }, 141 | Minute(6f) { 142 | override fun handValue(current: CurrentTime): Float = current.minutes.toFloat() 143 | }, 144 | Second(6f) { 145 | override fun handValue(current: CurrentTime): Float = current.seconds.toFloat() 146 | }; 147 | 148 | abstract fun handValue(current: CurrentTime): Float 149 | } 150 | 151 | @Composable 152 | private fun HansieCenterDot() { 153 | val isAmbient by LocalIsAmbient.current 154 | OClockCanvas { 155 | drawCircle(kotlinDarkBg, radius = 6.dp.toPx(), blendMode = BlendMode.Xor) 156 | drawCircle( 157 | color = if (isAmbient) Color.Black else kotlinLogoColors[2], 158 | radius = 5.dp.toPx() 159 | ) 160 | if (isAmbient) { 161 | drawCircle( 162 | Color.Gray, 163 | radius = 5.dp.toPx() 164 | ) 165 | drawCircle( 166 | Color.Black, 167 | radius = 2.dp.toPx() 168 | ) 169 | } 170 | } 171 | } 172 | 173 | private val kotlinDarkBg = Color(0xFF1B1B1B) 174 | private val kotlinBlue = Color(0xFF7F52FF) 175 | private val kotlinLogoColors = listOf( 176 | kotlinBlue, 177 | Color(0xFF_C811E2), 178 | Color(0xFF_E54857), 179 | ) 180 | 181 | @WatchFacePreview 182 | @Composable 183 | private fun HansieClockPreview( 184 | ) = WatchFacePreview(WatchFacePreview.Size.large) { 185 | HansieClock(Locale.US) 186 | } 187 | 188 | @WatchFacePreview 189 | @Composable 190 | private fun HansieClockPreviewFr( 191 | ) = WatchFacePreview(WatchFacePreview.Size.large) { 192 | HansieClock(Locale.FRENCH) 193 | } 194 | 195 | @WatchFacePreview 196 | @Composable 197 | private fun HansieClockPreviewSa( 198 | ) = WatchFacePreview(WatchFacePreview.Size.large) { 199 | HansieClock(Locale("af", "ZA")) 200 | } 201 | -------------------------------------------------------------------------------- /shared/src/main/res/drawable/jetpack_compose.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 14 | 15 | 18 | 19 | 22 | 23 | 26 | 27 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /shared/src/main/res/font/outfit_extrabold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/main/res/font/outfit_extrabold.ttf -------------------------------------------------------------------------------- /shared/src/main/res/values/font_certs.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | @array/com_google_android_gms_fonts_certs_dev 6 | @array/com_google_android_gms_fonts_certs_prod 7 | 8 | 9 | 10 | MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= 11 | 12 | 13 | 14 | 15 | MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /shared/src/test/kotlin/org/splitties/compose/oclock/sample/watchfaces/BasicAnalogClockTest.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.watchfaces 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | class BasicAnalogClockTest(device: WearDevice) : DeviceClockScreenshotTest(device) { 6 | @Composable 7 | override fun Clock() { 8 | BasicAnalogClock() 9 | } 10 | } -------------------------------------------------------------------------------- /shared/src/test/kotlin/org/splitties/compose/oclock/sample/watchfaces/ClockScreenshotTest.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalRoborazziApi::class) 2 | 3 | package org.splitties.compose.oclock.sample.watchfaces 4 | 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.test.junit4.createComposeRule 9 | import androidx.compose.ui.test.onRoot 10 | import com.github.takahirom.roborazzi.ExperimentalRoborazziApi 11 | import com.github.takahirom.roborazzi.RoborazziOptions 12 | import com.github.takahirom.roborazzi.ThresholdValidator 13 | import com.github.takahirom.roborazzi.captureRoboImage 14 | import com.louiscad.composeoclockplayground.shared.BuildConfig 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import org.junit.Assume 17 | import org.junit.Assume.assumeFalse 18 | import org.junit.Before 19 | import org.junit.Rule 20 | import org.junit.rules.TestRule 21 | import org.robolectric.RuntimeEnvironment 22 | import org.robolectric.annotation.Config 23 | import org.robolectric.annotation.GraphicsMode 24 | import org.splitties.compose.oclock.OClockRootCanvas 25 | import java.io.File 26 | 27 | @Config( 28 | sdk = [33], 29 | qualifiers = "w227dp-h227dp-small-notlong-round-watch-xhdpi-keyshidden-nonav", 30 | ) 31 | @GraphicsMode(GraphicsMode.Mode.NATIVE) 32 | abstract class ClockScreenshotTest { 33 | @get:Rule(order = 0) 34 | val debugOnly = TestRule { base, _ -> 35 | Assume.assumeTrue("Screenshot tests not supported for release", BuildConfig.DEBUG) 36 | 37 | base 38 | } 39 | 40 | @get:Rule(order = 1) 41 | val composeRule = createComposeRule() 42 | 43 | abstract val device: WearDevice 44 | 45 | // generous to allow for mac/linux differences 46 | open val tolerance = 0.02f 47 | 48 | open val roborazziOptions: RoborazziOptions 49 | get() = RoborazziOptions( 50 | compareOptions = RoborazziOptions.CompareOptions( 51 | resultValidator = ThresholdValidator(tolerance) 52 | ) 53 | ) 54 | 55 | @Before 56 | fun check() { 57 | // https://github.com/robolectric/robolectric/issues/8312 58 | assumeFalse("Robolectric RNG not supported on Windows", System.getProperty("os.name")?.startsWith("Windows") ?: false) 59 | } 60 | 61 | fun runTest(isAmbient: Boolean = false, clock: @Composable () -> Unit) { 62 | val filePath = 63 | File("src/test/screenshots/${this.javaClass.simpleName}_${device.id}${if (isAmbient) "_ambient" else ""}.png") 64 | 65 | RuntimeEnvironment.setQualifiers("+w${device.dp}dp-h${device.dp}dp") 66 | 67 | composeRule.setContent { 68 | OClockRootCanvas( 69 | modifier = Modifier.fillMaxSize(), isAmbientFlow = MutableStateFlow(isAmbient) 70 | ) { 71 | clock() 72 | } 73 | } 74 | 75 | composeRule.onRoot() 76 | .captureRoboImage(filePath = filePath.path, roborazziOptions = roborazziOptions) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /shared/src/test/kotlin/org/splitties/compose/oclock/sample/watchfaces/ComposeFanClockTest.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.watchfaces 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | class ComposeFanClockTest(device: WearDevice) : DeviceClockScreenshotTest(device) { 6 | @Composable 7 | override fun Clock() { 8 | ComposeFanClock() 9 | } 10 | } -------------------------------------------------------------------------------- /shared/src/test/kotlin/org/splitties/compose/oclock/sample/watchfaces/DeviceClockScreenshotTest.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.watchfaces 2 | 3 | import androidx.compose.runtime.Composable 4 | import org.junit.Test 5 | import org.junit.runner.RunWith 6 | import org.robolectric.ParameterizedRobolectricTestRunner 7 | import org.splitties.compose.oclock.sample.watchfaces.WearDevice.Companion.GooglePixelWatch 8 | import org.splitties.compose.oclock.sample.watchfaces.WearDevice.Companion.MobvoiTicWatchPro5 9 | import org.splitties.compose.oclock.sample.watchfaces.WearDevice.Companion.SamsungGalaxyWatch5 10 | 11 | @RunWith(ParameterizedRobolectricTestRunner::class) 12 | abstract class DeviceClockScreenshotTest(override val device: WearDevice): ClockScreenshotTest() { 13 | 14 | @Test 15 | fun interactive() = runTest { 16 | Clock() 17 | } 18 | 19 | @Test 20 | fun ambient() = runTest(isAmbient = true) { 21 | Clock() 22 | } 23 | 24 | @Composable 25 | abstract fun Clock() 26 | 27 | companion object { 28 | @JvmStatic 29 | @ParameterizedRobolectricTestRunner.Parameters 30 | fun devices() = listOf( 31 | MobvoiTicWatchPro5, 32 | SamsungGalaxyWatch5, 33 | GooglePixelWatch, 34 | ) 35 | } 36 | } -------------------------------------------------------------------------------- /shared/src/test/kotlin/org/splitties/compose/oclock/sample/watchfaces/Devices.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.watchfaces 2 | 3 | import kotlin.math.roundToInt 4 | 5 | data class WearDevice( 6 | val id: String, 7 | val modelName: String, 8 | val px: Int, 9 | val density: Float 10 | ) { 11 | val dp: Int = (px / density).roundToInt() 12 | 13 | companion object { 14 | val MobvoiTicWatchPro5: WearDevice = WearDevice( 15 | id = "ticwatch_pro_5", 16 | modelName = "Mobvoi TicWatch Pro 5", 17 | px = 466, 18 | density = 2.0f, 19 | ) 20 | 21 | val SamsungGalaxyWatch5: WearDevice = WearDevice( 22 | id = "galaxy_watch_5", 23 | modelName = "Samsung Galaxy Watch 5", 24 | px = 396, 25 | density = 2.0f, 26 | ) 27 | 28 | val GooglePixelWatch: WearDevice = WearDevice( 29 | id = "pixelwatch", 30 | modelName = "Google Pixel Watch", 31 | px = 384, 32 | density = 2.0f, 33 | ) 34 | } 35 | } -------------------------------------------------------------------------------- /shared/src/test/kotlin/org/splitties/compose/oclock/sample/watchfaces/KotlinFanClockTest.kt: -------------------------------------------------------------------------------- 1 | package org.splitties.compose.oclock.sample.watchfaces 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | class KotlinFanClockTest(device: WearDevice) : DeviceClockScreenshotTest(device) { 6 | @Composable 7 | override fun Clock() { 8 | KotlinFanClock() 9 | } 10 | } -------------------------------------------------------------------------------- /shared/src/test/screenshots/BasicAnalogClockTest_galaxy_watch_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/BasicAnalogClockTest_galaxy_watch_5.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/BasicAnalogClockTest_galaxy_watch_5_ambient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/BasicAnalogClockTest_galaxy_watch_5_ambient.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/BasicAnalogClockTest_pixelwatch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/BasicAnalogClockTest_pixelwatch.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/BasicAnalogClockTest_pixelwatch_ambient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/BasicAnalogClockTest_pixelwatch_ambient.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/BasicAnalogClockTest_ticwatch_pro_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/BasicAnalogClockTest_ticwatch_pro_5.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/BasicAnalogClockTest_ticwatch_pro_5_ambient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/BasicAnalogClockTest_ticwatch_pro_5_ambient.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/ComposeFanClockTest_galaxy_watch_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/ComposeFanClockTest_galaxy_watch_5.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/ComposeFanClockTest_galaxy_watch_5_ambient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/ComposeFanClockTest_galaxy_watch_5_ambient.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/ComposeFanClockTest_pixelwatch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/ComposeFanClockTest_pixelwatch.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/ComposeFanClockTest_pixelwatch_ambient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/ComposeFanClockTest_pixelwatch_ambient.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/ComposeFanClockTest_ticwatch_pro_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/ComposeFanClockTest_ticwatch_pro_5.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/ComposeFanClockTest_ticwatch_pro_5_ambient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/ComposeFanClockTest_ticwatch_pro_5_ambient.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/KotlinFanClockTest_galaxy_watch_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/KotlinFanClockTest_galaxy_watch_5.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/KotlinFanClockTest_galaxy_watch_5_ambient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/KotlinFanClockTest_galaxy_watch_5_ambient.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/KotlinFanClockTest_pixelwatch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/KotlinFanClockTest_pixelwatch.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/KotlinFanClockTest_pixelwatch_ambient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/KotlinFanClockTest_pixelwatch_ambient.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/KotlinFanClockTest_ticwatch_pro_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/KotlinFanClockTest_ticwatch_pro_5.png -------------------------------------------------------------------------------- /shared/src/test/screenshots/KotlinFanClockTest_ticwatch_pro_5_ambient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Splitties/ComposeOClock/a95a16419c0df890e8941c8834d81a9d934bebae/shared/src/test/screenshots/KotlinFanClockTest_ticwatch_pro_5_ambient.png -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 1.0 2 | -------------------------------------------------------------------------------- /versions.properties: -------------------------------------------------------------------------------- 1 | #### Dependencies and Plugin versions with their available updates. 2 | #### Generated by `./gradlew refreshVersions` version 0.60.5 3 | #### 4 | #### Don't manually edit or split the comments that start with four hashtags (####), 5 | #### they will be overwritten by refreshVersions. 6 | #### 7 | #### suppress inspection "SpellCheckingInspection" for whole file 8 | #### suppress inspection "UnusedProperty" for whole file 9 | 10 | plugin.android=8.5.0 11 | 12 | plugin.de.fayard.refreshVersions=0.60.5 13 | 14 | plugin.org.splitties.dependencies-dsl=0.2.0 15 | 16 | version.android.tools.desugar_jdk_libs=2.0.4 17 | 18 | version.androidx.activity=1.8.2 19 | ## # available=1.9.0-alpha01 20 | ## # available=1.9.0-alpha02 21 | 22 | version.androidx.compose.foundation=1.6.0 23 | ## # available=1.7.0-alpha01 24 | 25 | version.androidx.compose.material3=1.1.2 26 | ## # available=1.2.0-alpha01 27 | ## # available=1.2.0-alpha02 28 | ## # available=1.2.0-alpha03 29 | ## # available=1.2.0-alpha04 30 | ## # available=1.2.0-alpha05 31 | ## # available=1.2.0-alpha06 32 | ## # available=1.2.0-alpha07 33 | ## # available=1.2.0-alpha08 34 | ## # available=1.2.0-alpha09 35 | ## # available=1.2.0-alpha10 36 | ## # available=1.2.0-alpha11 37 | ## # available=1.2.0-alpha12 38 | ## # available=1.2.0-beta01 39 | ## # available=1.2.0-beta02 40 | ## # available=1.2.0-rc01 41 | 42 | version.androidx.compose.runtime=1.7.0-beta03 43 | 44 | version.androidx.compose.ui=1.7.0-beta03 45 | 46 | version.androidx.core=1.12.0 47 | ## # available=1.13.0-alpha01 48 | ## # available=1.13.0-alpha02 49 | ## # available=1.13.0-alpha03 50 | ## # available=1.13.0-alpha04 51 | 52 | version.androidx.core-splashscreen=1.0.1 53 | ## # available=1.1.0-alpha01 54 | ## # available=1.1.0-alpha02 55 | 56 | version.androidx.lifecycle=2.7.0 57 | ## # available=2.8.0-alpha01 58 | 59 | version.androidx.test.espresso=3.5.1 60 | ## # available=3.6.0-alpha01 61 | ## # available=3.6.0-alpha02 62 | ## # available=3.6.0-alpha03 63 | 64 | version.androidx.test.ext.junit=1.1.5 65 | ## # available=1.2.0-alpha01 66 | ## # available=1.2.0-alpha02 67 | ## # available=1.2.0-alpha03 68 | 69 | version.androidx.test.runner=1.5.2 70 | ## # available=1.5.3-alpha01 71 | ## # available=1.6.0-alpha01 72 | ## # available=1.6.0-alpha02 73 | ## # available=1.6.0-alpha03 74 | ## # available=1.6.0-alpha04 75 | ## # available=1.6.0-alpha05 76 | ## # available=1.6.0-alpha06 77 | 78 | version.androidx.wear.compose=1.3.0 79 | ## # available=1.4.0-alpha01 80 | 81 | version.androidx.wear.watchface-editor=1.2.1 82 | 83 | version.com.google.gms..google-services=4.4.0 84 | 85 | version.firebase-crashlytics-gradle=2.9.9 86 | 87 | version.google.android.play-services-wearable=18.1.0 88 | 89 | version.junit.junit=4.13.2 90 | 91 | version.kotlin=2.0.0 92 | 93 | version.robolectric=4.11.1 94 | 95 | version.splitties=3.0.0 96 | ## # available=3.1.0-SNAPSHOT 97 | --------------------------------------------------------------------------------