diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..6a940a3
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+/compiler.xml
+/deploymentTargetDropDown.xml
+/gradle.xml
+/kotlinc.xml
+/migrations.xml
+/misc.xml
+/vcs.xml
diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 0000000..570f7c8
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+ComposeOClock sample
\ No newline at end of file
diff --git a/.idea/runConfigurations/app_watch__installDebug_.xml b/.idea/runConfigurations/app_watch__installDebug_.xml
new file mode 100644
index 0000000..0e8fdc0
--- /dev/null
+++ b/.idea/runConfigurations/app_watch__installDebug_.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+ false
+ true
+ false
+ false
+
+
+
diff --git a/app-phone/.gitignore b/app-phone/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app-phone/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts
new file mode 100644
index 0000000..144f5af
--- /dev/null
+++ b/app-phone/build.gradle.kts
@@ -0,0 +1,49 @@
+plugins {
+ id("android-app")
+ id("version-code-phone")
+}
+
+android {
+ namespace = "com.louiscad.composeoclockplayground"
+
+ defaultConfig {
+ applicationId = "com.louiscad.composeoclockplayground"
+ minSdk = 26
+ targetSdk = 34
+ versionName = version.toString()
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+ compileOptions {
+ isCoreLibraryDesugaringEnabled = true
+ }
+}
+
+dependencies {
+ implementation {
+ project(":shared")()
+
+ AndroidX.compose.material3()
+ AndroidX.compose.ui.toolingPreview()
+ AndroidX.activity.compose()
+ platform(AndroidX.compose.bom)
+ AndroidX.compose.ui()
+ AndroidX.compose.ui.graphics()
+ AndroidX.compose.ui.toolingPreview()
+ AndroidX.compose.material3()
+ }
+ coreLibraryDesugaring(Android.tools.desugarJdkLibs)
+ testImplementation {
+ Testing.junit4()
+ }
+ androidTestImplementation {
+ AndroidX.test.ext.junit()
+ AndroidX.test.espresso.core()
+ platform(AndroidX.compose.bom)
+ AndroidX.compose.ui.testJunit4()
+ }
+ debugImplementation {
+ platform(AndroidX.compose.bom)
+ AndroidX.compose.ui.tooling()
+ AndroidX.compose.ui.testManifest()
+ }
+}
diff --git a/app-phone/proguard-rules.pro b/app-phone/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app-phone/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app-phone/src/main/AndroidManifest.xml b/app-phone/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5d1ae9a
--- /dev/null
+++ b/app-phone/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app-phone/src/main/java/com/louiscad/composeoclockplayground/MainActivity.kt b/app-phone/src/main/java/com/louiscad/composeoclockplayground/MainActivity.kt
new file mode 100644
index 0000000..908dca4
--- /dev/null
+++ b/app-phone/src/main/java/com/louiscad/composeoclockplayground/MainActivity.kt
@@ -0,0 +1,51 @@
+package com.louiscad.composeoclockplayground
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import com.louiscad.composeoclockplayground.ui.theme.MyComposeOClockPlaygroundTheme
+import org.splitties.compose.oclock.OClockRootCanvas
+import org.splitties.compose.oclock.sample.watchfaces.WatchFaceSwitcher
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MyComposeOClockPlaygroundTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ Content(Modifier.padding(innerPadding))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun Content(modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center
+ ) {
+ OClockRootCanvas {
+ WatchFaceSwitcher()
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun ContentPreview() {
+ MyComposeOClockPlaygroundTheme {
+ Content()
+ }
+}
diff --git a/app-phone/src/main/java/com/louiscad/composeoclockplayground/ui/theme/Color.kt b/app-phone/src/main/java/com/louiscad/composeoclockplayground/ui/theme/Color.kt
new file mode 100644
index 0000000..1cc75e3
--- /dev/null
+++ b/app-phone/src/main/java/com/louiscad/composeoclockplayground/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.louiscad.composeoclockplayground.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/app-phone/src/main/java/com/louiscad/composeoclockplayground/ui/theme/Theme.kt b/app-phone/src/main/java/com/louiscad/composeoclockplayground/ui/theme/Theme.kt
new file mode 100644
index 0000000..7f03ff6
--- /dev/null
+++ b/app-phone/src/main/java/com/louiscad/composeoclockplayground/ui/theme/Theme.kt
@@ -0,0 +1,58 @@
+package com.louiscad.composeoclockplayground.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun MyComposeOClockPlaygroundTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
diff --git a/app-phone/src/main/java/com/louiscad/composeoclockplayground/ui/theme/Type.kt b/app-phone/src/main/java/com/louiscad/composeoclockplayground/ui/theme/Type.kt
new file mode 100644
index 0000000..d2f74c2
--- /dev/null
+++ b/app-phone/src/main/java/com/louiscad/composeoclockplayground/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package com.louiscad.composeoclockplayground.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/app-phone/src/main/res/drawable/ic_launcher_background.xml b/app-phone/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/app-phone/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app-phone/src/main/res/drawable/ic_launcher_foreground.xml b/app-phone/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..7706ab9
--- /dev/null
+++ b/app-phone/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app-phone/src/main/res/mipmap-anydpi/ic_launcher.xml b/app-phone/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000..b3e26b4
--- /dev/null
+++ b/app-phone/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app-phone/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app-phone/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 0000000..b3e26b4
--- /dev/null
+++ b/app-phone/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app-phone/src/main/res/mipmap-hdpi/ic_launcher.webp b/app-phone/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/app-phone/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app-phone/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app-phone/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/app-phone/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app-phone/src/main/res/mipmap-mdpi/ic_launcher.webp b/app-phone/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/app-phone/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app-phone/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app-phone/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/app-phone/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app-phone/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app-phone/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/app-phone/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app-phone/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app-phone/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/app-phone/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app-phone/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app-phone/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/app-phone/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app-phone/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app-phone/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/app-phone/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app-phone/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app-phone/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/app-phone/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app-phone/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app-phone/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/app-phone/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app-phone/src/main/res/values/colors.xml b/app-phone/src/main/res/values/colors.xml
new file mode 100644
index 0000000..ca1931b
--- /dev/null
+++ b/app-phone/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
diff --git a/app-phone/src/main/res/values/strings.xml b/app-phone/src/main/res/values/strings.xml
new file mode 100644
index 0000000..1499ff7
--- /dev/null
+++ b/app-phone/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ My ComposeOClock Playground
+
\ No newline at end of file
diff --git a/app-phone/src/main/res/values/themes.xml b/app-phone/src/main/res/values/themes.xml
new file mode 100644
index 0000000..be5fcb4
--- /dev/null
+++ b/app-phone/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app-phone/src/main/res/xml/backup_rules.xml b/app-phone/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..148c18b
--- /dev/null
+++ b/app-phone/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app-phone/src/main/res/xml/data_extraction_rules.xml b/app-phone/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..0c4f95c
--- /dev/null
+++ b/app-phone/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/app-phone/src/test/java/com/louiscad/composeoclockplayground/ExampleUnitTest.kt b/app-phone/src/test/java/com/louiscad/composeoclockplayground/ExampleUnitTest.kt
new file mode 100644
index 0000000..f4f5429
--- /dev/null
+++ b/app-phone/src/test/java/com/louiscad/composeoclockplayground/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.louiscad.composeoclockplayground
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
diff --git a/app-watch/.gitignore b/app-watch/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app-watch/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app-watch/build.gradle.kts b/app-watch/build.gradle.kts
new file mode 100644
index 0000000..d909881
--- /dev/null
+++ b/app-watch/build.gradle.kts
@@ -0,0 +1,45 @@
+plugins {
+ id("android-app")
+ id("version-code-watch")
+}
+
+android {
+ namespace = "com.louiscad.composeoclockplayground"
+
+ defaultConfig {
+ applicationId = "com.louiscad.composeoclockplayground"
+ minSdk = 26
+ targetSdk = 33
+ versionName = version.toString()
+ }
+ compileOptions {
+ isCoreLibraryDesugaringEnabled = true
+ }
+}
+
+dependencies {
+ coreLibraryDesugaring(Android.tools.desugarJdkLibs)
+ implementation {
+ project(":shared")()
+ libs.compose.oclock.watchface.renderer()
+ AndroidX.wear.watchFace.editor()
+
+ platform(AndroidX.compose.bom)
+ AndroidX.wear.compose.material()
+ AndroidX.wear.compose.foundation()
+ AndroidX.activity.compose()
+ AndroidX.core.splashscreen()
+
+ Splitties.systemservices()
+ Splitties.toast()
+ }
+ androidTestImplementation {
+ platform(AndroidX.compose.bom)
+ AndroidX.compose.ui.testJunit4()
+ }
+ debugImplementation {
+ platform(AndroidX.compose.bom)
+ AndroidX.compose.ui.tooling()
+ AndroidX.compose.ui.testManifest()
+ }
+}
diff --git a/app-watch/lint.xml b/app-watch/lint.xml
new file mode 100644
index 0000000..44fac75
--- /dev/null
+++ b/app-watch/lint.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-watch/proguard-rules.pro b/app-watch/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app-watch/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app-watch/src/main/AndroidManifest.xml b/app-watch/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..b1c2615
--- /dev/null
+++ b/app-watch/src/main/AndroidManifest.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app-watch/src/main/kotlin/MainActivity.kt b/app-watch/src/main/kotlin/MainActivity.kt
new file mode 100644
index 0000000..330acf6
--- /dev/null
+++ b/app-watch/src/main/kotlin/MainActivity.kt
@@ -0,0 +1,72 @@
+/* While this template provides a good starting point for using Wear Compose, you can always
+ * take a look at https://github.com/android/wear-os-samples/tree/main/ComposeStarter and
+ * https://github.com/android/wear-os-samples/tree/main/ComposeAdvanced to find the most up to date
+ * changes to the libraries and their usages.
+ */
+
+package com.louiscad.composeoclockplayground
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.wear.compose.material.MaterialTheme
+import androidx.wear.compose.material.Text
+import androidx.wear.compose.material.TimeText
+import com.louiscad.composeoclockplayground.presentation.theme.MyComposeOClockPlaygroundTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen()
+
+ super.onCreate(savedInstanceState)
+
+ setTheme(android.R.style.Theme_DeviceDefault)
+
+ setContent {
+ WearApp("Android")
+ }
+ }
+}
+
+@Composable
+fun WearApp(greetingName: String) {
+ MyComposeOClockPlaygroundTheme {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colors.background),
+ contentAlignment = Alignment.Center
+ ) {
+ TimeText()
+ Greeting(greetingName = greetingName)
+ }
+ }
+}
+
+@Composable
+fun Greeting(greetingName: String) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colors.primary,
+ text = stringResource(R.string.hello_world, greetingName)
+ )
+}
+
+@Preview(device = Devices.WEAR_OS_SMALL_ROUND, showSystemUi = true)
+@Composable
+fun DefaultPreview() {
+ WearApp("Preview Android")
+}
diff --git a/app-watch/src/main/kotlin/SampleWatchFaceService.kt b/app-watch/src/main/kotlin/SampleWatchFaceService.kt
new file mode 100644
index 0000000..3ff4072
--- /dev/null
+++ b/app-watch/src/main/kotlin/SampleWatchFaceService.kt
@@ -0,0 +1,25 @@
+package com.louiscad.composeoclockplayground
+
+import androidx.compose.runtime.Composable
+import androidx.wear.watchface.complications.data.ComplicationData
+import androidx.wear.watchface.complications.data.ComplicationType
+import kotlinx.coroutines.flow.*
+import org.splitties.compose.oclock.ComposeWatchFaceService
+import org.splitties.compose.oclock.sample.watchfaces.KotlinFanClock
+import org.splitties.compose.oclock.sample.watchfaces.WatchFaceSwitcher
+
+class SampleWatchFaceService : ComposeWatchFaceService(
+ complicationSlotIds = emptySet(),
+ invalidationMode = InvalidationMode.WaitForInvalidation
+) {
+
+ @Composable
+ override fun Content(complicationData: Map>) {
+ WatchFaceSwitcher()
+ }
+
+ override fun supportedComplicationTypes(slotId: Int) = listOf(
+ ComplicationType.RANGED_VALUE,
+ ComplicationType.SHORT_TEXT
+ )
+}
diff --git a/app-watch/src/main/kotlin/WatchFaceConfigActivity.kt b/app-watch/src/main/kotlin/WatchFaceConfigActivity.kt
new file mode 100644
index 0000000..e71601a
--- /dev/null
+++ b/app-watch/src/main/kotlin/WatchFaceConfigActivity.kt
@@ -0,0 +1,46 @@
+package com.louiscad.composeoclockplayground
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.provider.Settings
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.lifecycle.lifecycleScope
+import androidx.wear.watchface.editor.EditorSession
+import com.louiscad.composeoclockplayground.editor.WatchFaceConfigContent
+import com.louiscad.composeoclockplayground.editor.WatchFaceEditorSession
+import kotlinx.coroutines.*
+import splitties.toast.longToast
+import splitties.toast.toast
+
+class WatchFaceConfigActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
+ val activity = this@WatchFaceConfigActivity
+ val editorSession = WatchFaceEditorSession(
+ scope = lifecycleScope,
+ session = EditorSession.createOnWatchEditorSession(activity)
+ )
+ setContent {
+ WatchFaceConfigContent(
+ editorSession = editorSession,
+ )
+ }
+ if (Settings.System.canWrite(applicationContext)) {
+ toast("Can write settings!")
+ } else {
+ longToast( "Go into \"Advcanced\"…")
+ longToast( "and enable \"Modify system settings\"")
+ startActivity(
+ Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", packageName, null)
+ )
+ )
+ }
+ }
+ }
+}
diff --git a/app-watch/src/main/kotlin/editor/WatchFaceConfigContent.kt b/app-watch/src/main/kotlin/editor/WatchFaceConfigContent.kt
new file mode 100644
index 0000000..b5dc9ba
--- /dev/null
+++ b/app-watch/src/main/kotlin/editor/WatchFaceConfigContent.kt
@@ -0,0 +1,18 @@
+package com.louiscad.composeoclockplayground.editor
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.wear.compose.material.Text
+
+@Composable
+fun WatchFaceConfigContent(editorSession: WatchFaceEditorSession) {
+ Box(
+ Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("Nothing there yet…")
+ }
+}
diff --git a/app-watch/src/main/kotlin/editor/WatchFaceEditorSession.kt b/app-watch/src/main/kotlin/editor/WatchFaceEditorSession.kt
new file mode 100644
index 0000000..02ffe4f
--- /dev/null
+++ b/app-watch/src/main/kotlin/editor/WatchFaceEditorSession.kt
@@ -0,0 +1,51 @@
+package com.louiscad.composeoclockplayground.editor
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.wear.watchface.complications.ComplicationDataSourceInfo
+import androidx.wear.watchface.editor.EditorSession
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+
+class WatchFaceEditorSession(
+ scope: CoroutineScope,
+ val session: EditorSession?
+) {
+ fun tryOpenDataSourcePicker(complicationId: Int) {
+ flow.tryEmit(complicationId)
+ }
+
+ fun complicationDataSourceInfo(id: Int): Flow = session?.let {
+ it.complicationsDataSourceInfo.map { map -> map[id] }
+ } ?: flowOf(null)
+
+ private val isBusyPickingComplicationState = mutableStateOf(false)
+
+ val isBusyPickingDataSource: Boolean by isBusyPickingComplicationState
+
+ private val flow = MutableSharedFlow(extraBufferCapacity = 1)
+
+ init {
+ scope.launch {
+ flow.collect { id ->
+ isBusyPickingComplicationState.withValue(valueInScope = true) {
+ session?.openComplicationDataSourceChooser(id)
+ }
+ }
+ }
+ }
+}
+
+private inline fun MutableState.withValue(
+ valueInScope: T,
+ block: () -> R
+): R {
+ val initialValue = value
+ try {
+ value = valueInScope
+ return block()
+ } finally {
+ value = initialValue
+ }
+}
diff --git a/app-watch/src/main/kotlin/presentation/theme/Theme.kt b/app-watch/src/main/kotlin/presentation/theme/Theme.kt
new file mode 100644
index 0000000..0a35d7d
--- /dev/null
+++ b/app-watch/src/main/kotlin/presentation/theme/Theme.kt
@@ -0,0 +1,17 @@
+package com.louiscad.composeoclockplayground.presentation.theme
+
+import androidx.compose.runtime.Composable
+import androidx.wear.compose.material.MaterialTheme
+
+@Composable
+fun MyComposeOClockPlaygroundTheme(
+ content: @Composable () -> Unit
+) {
+ /**
+ * Empty theme to customize for your app.
+ * See: https://developer.android.com/jetpack/compose/designsystems/custom
+ */
+ MaterialTheme(
+ content = content
+ )
+}
diff --git a/app-watch/src/main/res/drawable/splash_icon.xml b/app-watch/src/main/res/drawable/splash_icon.xml
new file mode 100644
index 0000000..7874e83
--- /dev/null
+++ b/app-watch/src/main/res/drawable/splash_icon.xml
@@ -0,0 +1,27 @@
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
diff --git a/app-watch/src/main/res/drawable/watch_preview.png b/app-watch/src/main/res/drawable/watch_preview.png
new file mode 100644
index 0000000..480fd55
Binary files /dev/null and b/app-watch/src/main/res/drawable/watch_preview.png differ
diff --git a/app-watch/src/main/res/mipmap-hdpi/ic_launcher.webp b/app-watch/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/app-watch/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app-watch/src/main/res/mipmap-mdpi/ic_launcher.webp b/app-watch/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/app-watch/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app-watch/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app-watch/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/app-watch/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app-watch/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app-watch/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/app-watch/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app-watch/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app-watch/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/app-watch/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app-watch/src/main/res/values-round/strings.xml b/app-watch/src/main/res/values-round/strings.xml
new file mode 100644
index 0000000..42f1229
--- /dev/null
+++ b/app-watch/src/main/res/values-round/strings.xml
@@ -0,0 +1,3 @@
+
+ From the Round world,\nHello, %1$s!
+
\ No newline at end of file
diff --git a/app-watch/src/main/res/values/strings.xml b/app-watch/src/main/res/values/strings.xml
new file mode 100644
index 0000000..65188da
--- /dev/null
+++ b/app-watch/src/main/res/values/strings.xml
@@ -0,0 +1,8 @@
+
+ My ComposeOClock Playground
+
+ From the Square world,\nHello, %1$s!
+
\ No newline at end of file
diff --git a/app-watch/src/main/res/values/styles.xml b/app-watch/src/main/res/values/styles.xml
new file mode 100644
index 0000000..85dec6d
--- /dev/null
+++ b/app-watch/src/main/res/values/styles.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/app-watch/src/main/res/xml/watch_face.xml b/app-watch/src/main/res/xml/watch_face.xml
new file mode 100644
index 0000000..7628e14
--- /dev/null
+++ b/app-watch/src/main/res/xml/watch_face.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..81ae933
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,6 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id("com.android.application") apply false
+ id("com.android.library") apply false
+ kotlin("android") apply false
+}
diff --git a/convention-plugins/.gitignore b/convention-plugins/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/convention-plugins/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/convention-plugins/build.gradle.kts b/convention-plugins/build.gradle.kts
new file mode 100644
index 0000000..300a1cc
--- /dev/null
+++ b/convention-plugins/build.gradle.kts
@@ -0,0 +1,23 @@
+plugins {
+ `kotlin-dsl`
+}
+
+repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ mavenLocal()
+}
+
+
+dependencies {
+ fun plugin(id: String, version: String) = "$id:$id.gradle.plugin:$version"
+ implementation {
+ Android.tools.build.gradlePlugin()
+ Kotlin.gradlePlugin()
+ Google.playServicesGradlePlugin()
+ Firebase.crashlyticsGradlePlugin()
+ plugin("de.fayard.refreshVersions", "_")()
+ plugin("org.splitties.dependencies-dsl", "_")()
+ }
+}
diff --git a/convention-plugins/gradle.properties b/convention-plugins/gradle.properties
new file mode 100644
index 0000000..cef9773
--- /dev/null
+++ b/convention-plugins/gradle.properties
@@ -0,0 +1,25 @@
+org.gradle.parallel=true
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+
+kotlin.daemon.useFallbackStrategy=false
+
+android.defaults.buildfeatures.aidl=false
+android.defaults.buildfeatures.renderscript=false
+
diff --git a/convention-plugins/settings.gradle.kts b/convention-plugins/settings.gradle.kts
new file mode 100644
index 0000000..7fb2b66
--- /dev/null
+++ b/convention-plugins/settings.gradle.kts
@@ -0,0 +1,20 @@
+pluginManagement {
+ repositories {
+ mavenCentral()
+ gradlePluginPortal()
+ mavenLocal()
+ }
+ plugins {
+ id("org.splitties.dependencies-dsl") version "0.1.0"
+ id("de.fayard.refreshVersions") version "0.60.4"
+ }
+}
+
+plugins {
+ id("org.splitties.dependencies-dsl")
+ id("de.fayard.refreshVersions")
+}
+
+refreshVersions {
+ versionsPropertiesFile = rootDir.parentFile.resolve("versions.properties")
+}
diff --git a/convention-plugins/src/main/kotlin/Versioning.kt b/convention-plugins/src/main/kotlin/Versioning.kt
new file mode 100644
index 0000000..93e71c5
--- /dev/null
+++ b/convention-plugins/src/main/kotlin/Versioning.kt
@@ -0,0 +1,27 @@
+package convention_plugins
+
+import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.the
+
+enum class AppKind {
+ Phone,
+ Watch
+}
+
+fun Project.setVersionCodeForApp(kind: AppKind) {
+ val android = the()
+ android.defaultConfig.versionCode = versionCode(kind)
+}
+
+private fun Project.versionCode(kind: AppKind): Int {
+ val releaseBuildNumber = rootProject.layout.projectDirectory.file(
+ "releaseBuildNumber.txt"
+ ).asFile.useLines {
+ it.first()
+ }.toInt().also { require(it > 0) }
+ return releaseBuildNumber * 2 - when (kind) {
+ AppKind.Phone -> 1
+ AppKind.Watch -> 0
+ }
+}
diff --git a/convention-plugins/src/main/kotlin/android-app.gradle.kts b/convention-plugins/src/main/kotlin/android-app.gradle.kts
new file mode 100644
index 0000000..b5a040b
--- /dev/null
+++ b/convention-plugins/src/main/kotlin/android-app.gradle.kts
@@ -0,0 +1,103 @@
+import de.fayard.refreshVersions.core.versionFor
+import java.io.FileNotFoundException
+
+plugins {
+ kotlin("android")
+ id("com.android.application")
+}
+
+android {
+ compileSdk = 34
+ defaultConfig {
+ minSdk = 26
+ targetSdk = 33
+ resourceConfigurations += setOf("en", "fr")
+ }
+ // We embed the signing keystore to allow updating from another computer without uninstalling.
+ val debugSigningConfig by signingConfigs.creating {
+ storeFile = rootProject.file("debug.keystore")
+ storePassword = "android"
+ keyAlias = "androiddebugkey"
+ keyPassword = "android"
+ }
+ val keystoreFile = (findProperty("androidKeyFile") as String?)?.let { keyFilePath ->
+ File(System.getProperty("user.home")).resolve(keyFilePath).also {
+ if (it.exists().not()) throw FileNotFoundException("Didn't find keystore file at $it")
+ }
+ }
+ val releaseSigningConfig = keystoreFile?.let {
+ signingConfigs.create("releaseSigningConfig") {
+ val androidKeyFile: String by project
+ storeFile = File(System.getProperty("user.home")).resolve(androidKeyFile)
+ val androidKeyAlias: String by project
+ val androidKeyUniversalMdp: String by project
+ storePassword = androidKeyUniversalMdp
+ keyAlias = androidKeyAlias
+ keyPassword = androidKeyUniversalMdp
+ }
+ }
+ buildTypes {
+ debug {
+ applicationIdSuffix = ".debug"
+ signingConfig = debugSigningConfig
+ }
+ create("staging") {
+ applicationIdSuffix = ".staging"
+ matchingFallbacks += "release"
+ isMinifyEnabled = true
+ isShrinkResources = true
+ isDebuggable = false
+ signingConfig = debugSigningConfig
+ }
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ signingConfig = releaseSigningConfig ?: debugSigningConfig
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ kotlinOptions {
+ freeCompilerArgs += "-opt-in=splitties.experimental.ExperimentalSplittiesApi"
+ jvmTarget = "1.8"
+ freeCompilerArgs += "-Xcontext-receivers"
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ buildFeatures.compose = true
+ composeOptions {
+ kotlinCompilerExtensionVersion = versionFor(AndroidX.compose.compiler)
+ }
+ packagingOptions.resources {
+ excludes += setOf(
+ "META-INF/ASL2.0",
+ "META-INF/AL2.0",
+ "META-INF/LGPL2.1",
+ "META-INF/LICENSE",
+ "META-INF/license.txt",
+ "META-INF/NOTICE",
+ "META-INF/notice.txt"
+ )
+
+ // Exclude files that unused kotlin-reflect would need, to make the app smaller:
+ // (see issue https://youtrack.jetbrains.com/issue/KT-9770)
+ excludes += setOf(
+ "META-INF/*.kotlin_module",
+ "kotlin/*.kotlin_builtins",
+ "kotlin/**/*.kotlin_builtins"
+ )
+ }
+}
+
+dependencies {
+ implementation {
+ AndroidX.activity.compose()
+
+ AndroidX.compose.runtime()
+ AndroidX.compose.foundation()
+ }
+}
diff --git a/convention-plugins/src/main/kotlin/android-crashlytics.gradle.kts b/convention-plugins/src/main/kotlin/android-crashlytics.gradle.kts
new file mode 100644
index 0000000..2ca8496
--- /dev/null
+++ b/convention-plugins/src/main/kotlin/android-crashlytics.gradle.kts
@@ -0,0 +1,14 @@
+plugins {
+ kotlin("android")
+ id("com.android.application")
+ id("com.google.gms.google-services")
+ id("com.google.firebase.crashlytics")
+}
+
+dependencies {
+ implementation {
+ platform(Firebase.bom)()
+ Firebase.analyticsKtx()
+ Firebase.crashlyticsKtx()
+ }
+}
diff --git a/convention-plugins/src/main/kotlin/android-lib.gradle.kts b/convention-plugins/src/main/kotlin/android-lib.gradle.kts
new file mode 100644
index 0000000..da3a3a8
--- /dev/null
+++ b/convention-plugins/src/main/kotlin/android-lib.gradle.kts
@@ -0,0 +1,24 @@
+plugins {
+ kotlin("android")
+ id("com.android.library")
+}
+
+android {
+ compileSdk = 34
+ defaultConfig {
+ minSdk = 23
+ }
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ freeCompilerArgs += "-Xcontext-receivers"
+ }
+}
diff --git a/convention-plugins/src/main/kotlin/android-signing-config.gradle.kts b/convention-plugins/src/main/kotlin/android-signing-config.gradle.kts
new file mode 100644
index 0000000..66b795e
--- /dev/null
+++ b/convention-plugins/src/main/kotlin/android-signing-config.gradle.kts
@@ -0,0 +1,38 @@
+import java.io.FileNotFoundException
+
+plugins {
+ id("com.android.application")
+}
+
+android {
+ val debugSigningConfig by signingConfigs.creating {
+ storeFile = rootProject.file("debug.keystore")
+ storePassword = "android"
+ keyAlias = "androiddebugkey"
+ keyPassword = "android"
+ }
+ val keystoreFile = (property("androidKeyFile") as String?)?.let { keyFilePath ->
+ File(System.getProperty("user.home")).resolve(keyFilePath).also {
+ if (it.exists().not()) throw FileNotFoundException("Didn't find keystore file at $it")
+ }
+ }
+ val releaseSigningConfig = keystoreFile?.let {
+ signingConfigs.create("releaseSigningConfig") {
+ val androidKeyFile: String by project
+ storeFile = File(System.getProperty("user.home")).resolve(androidKeyFile)
+ val androidKeyAlias: String by project
+ val androidKeyUniversalMdp: String by project
+ storePassword = androidKeyUniversalMdp
+ keyAlias = androidKeyAlias
+ keyPassword = androidKeyUniversalMdp
+ }
+ }
+ buildTypes {
+ debug {
+ signingConfig = debugSigningConfig
+ }
+ release {
+ signingConfig = releaseSigningConfig ?: debugSigningConfig
+ }
+ }
+}
diff --git a/convention-plugins/src/main/kotlin/version-code-phone.gradle.kts b/convention-plugins/src/main/kotlin/version-code-phone.gradle.kts
new file mode 100644
index 0000000..ef133ec
--- /dev/null
+++ b/convention-plugins/src/main/kotlin/version-code-phone.gradle.kts
@@ -0,0 +1,4 @@
+import convention_plugins.AppKind
+import convention_plugins.setVersionCodeForApp
+
+setVersionCodeForApp(AppKind.Phone)
diff --git a/convention-plugins/src/main/kotlin/version-code-watch.gradle.kts b/convention-plugins/src/main/kotlin/version-code-watch.gradle.kts
new file mode 100644
index 0000000..e4a964c
--- /dev/null
+++ b/convention-plugins/src/main/kotlin/version-code-watch.gradle.kts
@@ -0,0 +1,4 @@
+import convention_plugins.AppKind
+import convention_plugins.setVersionCodeForApp
+
+setVersionCodeForApp(AppKind.Watch)
diff --git a/debug.keystore b/debug.keystore
new file mode 100644
index 0000000..15c769e
Binary files /dev/null and b/debug.keystore differ
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..cef9773
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,25 @@
+org.gradle.parallel=true
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+
+kotlin.daemon.useFallbackStrategy=false
+
+android.defaults.buildfeatures.aidl=false
+android.defaults.buildfeatures.renderscript=false
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..2554d16
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,6 @@
+[versions]
+composeOClock = "0.1.0-SNAPSHOT"
+
+[libraries]
+compose-oclock-core = { group = "org.splitties.compose.oclock", name = "core", version.ref = "composeOClock" }
+compose-oclock-watchface-renderer = { group = "org.splitties.compose.oclock", name = "watchface-renderer", version.ref = "composeOClock" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..eba6203
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Sat Jan 20 17:23:23 CET 2024
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/releaseBuildNumber.txt b/releaseBuildNumber.txt
new file mode 100644
index 0000000..d00491f
--- /dev/null
+++ b/releaseBuildNumber.txt
@@ -0,0 +1 @@
+1
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..25dceae
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,49 @@
+pluginManagement {
+ includeBuild("convention-plugins")
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("de.fayard.refreshVersions") version "0.60.4"
+ id("org.splitties.settings-include-dsl") version "0.2.6"
+ id("org.splitties.dependencies-dsl") version "0.2.0"
+ id("org.splitties.version-sync") version "0.2.6"
+}
+
+run { // Remove when https://github.com/gradle/gradle/issues/2534 is fixed.
+ val rootProjectPropertiesFile = rootDir.resolve("gradle.properties")
+ val includedBuildPropertiesFile = rootDir.resolve("convention-plugins").resolve("gradle.properties")
+ if (includedBuildPropertiesFile.exists().not() ||
+ rootProjectPropertiesFile.readText() != includedBuildPropertiesFile.readText()
+ ) {
+ rootProjectPropertiesFile.copyTo(target = includedBuildPropertiesFile, overwrite = true)
+ }
+}
+
+@Suppress("UnstableApiUsage")
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots")
+ }
+}
+
+rootProject.name = "ComposeOClock sample"
+
+include {
+ "app-phone"()
+ "app-watch"()
+ "shared"()
+}
diff --git a/shared/.gitignore b/shared/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/shared/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts
new file mode 100644
index 0000000..049f195
--- /dev/null
+++ b/shared/build.gradle.kts
@@ -0,0 +1,55 @@
+import de.fayard.refreshVersions.core.versionFor
+
+plugins {
+ id("android-lib")
+}
+
+android {
+ namespace = "com.louiscad.composeoclockplayground.shared"
+
+ defaultConfig {
+ minSdk = 26
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = versionFor(AndroidX.compose.compiler)
+ }
+ compileOptions {
+ isCoreLibraryDesugaringEnabled = true
+ }
+}
+
+dependencies {
+ api {
+ libs.compose.oclock.core()
+ Google.android.playServices.wearOS()
+
+ platform(AndroidX.compose.bom)
+ AndroidX.compose.ui()
+ AndroidX.compose.ui.toolingPreview()
+ AndroidX.compose.ui.text.googleFonts()
+
+ AndroidX.core.ktx()
+ AndroidX.lifecycle.runtime.ktx()
+ }
+ coreLibraryDesugaring(Android.tools.desugarJdkLibs)
+ implementation {
+ Splitties.systemservices()
+ Splitties.appctx()
+ Splitties.bitflags()
+ }
+ debugImplementation {
+ AndroidX.compose.ui.tooling() //Important so previews can work.
+ }
+ testImplementation {
+ Testing.junit4()
+ }
+ androidTestImplementation {
+ AndroidX.test.ext.junit.ktx()
+ AndroidX.test.espresso.core()
+ }
+}
diff --git a/shared/consumer-rules.pro b/shared/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/shared/proguard-rules.pro b/shared/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/shared/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/shared/src/androidTest/java/com/louiscad/shared/ExampleInstrumentedTest.kt b/shared/src/androidTest/java/com/louiscad/shared/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..5aa5e6c
--- /dev/null
+++ b/shared/src/androidTest/java/com/louiscad/shared/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.louiscad.shared
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.louiscad.shared.test", appContext.packageName)
+ }
+}
diff --git a/shared/src/main/kotlin/ComposeOClockWatermark.kt b/shared/src/main/kotlin/ComposeOClockWatermark.kt
new file mode 100644
index 0000000..3fcf551
--- /dev/null
+++ b/shared/src/main/kotlin/ComposeOClockWatermark.kt
@@ -0,0 +1,107 @@
+package org.splitties.compose.oclock.sample
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.AndroidFont
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.googlefonts.Font
+import androidx.compose.ui.text.googlefonts.GoogleFont
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextMotion
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.splitties.compose.oclock.LocalIsAmbient
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.sample.extensions.drawTextOnPath
+import org.splitties.compose.oclock.sample.extensions.rememberStateWithSize
+import org.splitties.compose.oclock.sample.extensions.text.rememberTextOnPathMeasurer
+
+@Composable
+fun ComposeOClockWatermark(finalBrush: Brush) {
+ val font = remember {
+ Font(
+ googleFont = GoogleFont("Jost"),
+// googleFont = GoogleFont("Raleway"),
+// googleFont = GoogleFont("Reem Kufi"),
+// googleFont = GoogleFont("Montez"),
+ fontProvider = googleFontProvider,
+ ) as AndroidFont
+ }
+ val fontFamily = remember(font) { FontFamily(font) }
+ val context = LocalContext.current
+ val brush by produceState(finalBrush, font) {
+ val result = runCatching {
+ font.typefaceLoader.awaitLoad(context, font)
+ }
+ value = if (result.isSuccess) finalBrush else Brush.linearGradient(listOf(Color.Red))
+ }
+ val interactiveTextStyle = remember(fontFamily, brush) {
+ TextStyle.Default.copy(
+ brush = brush,
+ fontFamily = fontFamily,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.W600,
+ textAlign = TextAlign.Center,
+ lineHeight = 20.sp,
+ textMotion = TextMotion.Animated
+ )
+ }
+ val ambientTextStyle = rememberStateWithSize(interactiveTextStyle) {
+ interactiveTextStyle.copy(fontWeight = FontWeight.W300)
+ }
+ val textMeasurer = rememberTextOnPathMeasurer(cacheSize = 0)
+ val isAmbient by LocalIsAmbient.current
+ val cachedPath = remember { Path() }.let { path ->
+ rememberStateWithSize {
+ val minimumInset = interactiveTextStyle.fontSize.toPx()
+ path.arcTo(
+ rect = Rect(Offset.Zero, size).deflate(minimumInset + 4.dp.toPx()),
+ startAngleDegrees = 89.5f,
+ sweepAngleDegrees = 359f,
+ forceMoveTo = true
+ )
+ path
+ }
+ }
+ val string = "It's Compose O'Clock!"
+ val interactiveText = rememberStateWithSize {
+ textMeasurer.measure(
+ text = string,
+ style = interactiveTextStyle
+ )
+ }
+ val ambientText = rememberStateWithSize {
+ textMeasurer.measure(
+ text = string,
+ style = ambientTextStyle.get()
+ )
+ }
+ OClockCanvas {
+ val text = if (isAmbient) ambientText else interactiveText
+ drawTextOnPath(
+ textLayoutResult = text.get(),
+ path = cachedPath.get()
+ )
+ }
+}
+
+@WatchFacePreview
+@Composable
+private fun ComposeOClockWatermarkPreview(
+ @PreviewParameter(WearPreviewSizesProvider::class) size: Dp
+) = WatchFacePreview(size) {
+ ComposeOClockWatermark(SolidColor(Color.Magenta))
+}
diff --git a/shared/src/main/kotlin/ForPaper.kt b/shared/src/main/kotlin/ForPaper.kt
new file mode 100644
index 0000000..8bb7559
--- /dev/null
+++ b/shared/src/main/kotlin/ForPaper.kt
@@ -0,0 +1,450 @@
+package org.splitties.compose.oclock.sample
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.wear.watchface.complications.data.ComplicationData
+import kotlinx.coroutines.flow.*
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.OClockRootCanvas
+import org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements.circlesPattern
+import org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements.ovalPattern
+import org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements.wavyCirclesPattern
+
+
+private val gray = Color(0xff666666)
+private val lightGray = Color(0xffCCCCCC)
+private val blue = Color(0xFF0091EA)
+
+@MyPreview
+@Composable
+private fun MultiCirclesPreview() = RootCanvas {
+ OClockCanvas {
+ val side = size.minDimension
+ if (false) circlesPattern(
+ color = gray,
+ count = 6,
+ diameters = listOf(.1, .15, .2, .25).map { it.toFloat() * side / 2 }
+ )
+ rotate(30f) {
+ circlesPattern(
+ color = Color(0xFF5F04AF),
+ count = 12,
+ diameters = listOf(.1, .25).map { it.toFloat() * side * 2 }
+ )
+ }
+ /*circlesPattern(
+ color = blue.copy(alpha = .8f),
+ count = 90,
+ diameters = diameter,
+ edgeMargin = diameter * 1.1f
+ )
+ circlesPattern(
+ color = _root_ide_package_.org.splitties.sample.composeoclock.lightGray.copy(alpha = .4f),
+ count = 20,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f
+ )*/
+ }
+}
+
+@MyPreview
+@Composable
+private fun WavyCircles1Preview() = RootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ wavyCirclesPattern(color = gray, count = 180, diameters = diameter)
+ wavyCirclesPattern(color = blue.copy(alpha = .8f),count = 90, diameters = diameter, edgeMargin = diameter * 1.1f)
+ wavyCirclesPattern(color = lightGray.copy(alpha = .4f),count = 20, diameters = diameter, edgeMargin = diameter * 2.2f)
+ }
+}
+
+@MyPreview
+@Composable
+private fun WavyCircles2Preview() = RootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ val factor = 6f
+ wavyCirclesPattern(
+ color = blue.copy(alpha = .8f),
+ count = 90,
+ diameters = diameter,
+ edgeMargin = diameter * 1.2f,
+ travel = diameter / 2
+ )
+ wavyCirclesPattern(
+ color = gray,
+ count = 180,
+ diameters = diameter,
+ travel = diameter / factor,
+ periodAngle = 15f
+ )
+ circlesPattern(
+ color = lightGray.copy(alpha = .4f),
+ count = 8,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f,
+// travel = diameter / factor
+ )
+ }
+}
+
+@MyPreview
+@Composable
+private fun WavyCircles3Preview() = RootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ val factor = 6f
+ wavyCirclesPattern(
+ color = Color(0xFF5F04AF).copy(alpha = .8f),
+ count = 90,
+ diameters = diameter,
+ edgeMargin = diameter * 1.2f,
+ travel = diameter / 2
+ )
+ wavyCirclesPattern(
+ color = gray,
+ count = 180,
+ diameters = diameter,
+ travel = diameter / (factor / 3),
+ periodAngle = 60f
+ )
+ circlesPattern(
+ color = lightGray.copy(alpha = .4f),
+ count = 8,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f,
+// travel = diameter / factor
+ )
+ }
+}
+
+@MyPreview
+@Composable
+private fun WavyCircles4Preview() = RootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ val factor = 6f
+ if (false) wavyCirclesPattern(
+ color = blue.copy(alpha = .8f),
+ count = 90,
+ diameters = diameter,
+ edgeMargin = diameter * 1.2f,
+ travel = diameter / 2
+ )
+ wavyCirclesPattern(
+ color = Color(0xC60066FF),
+ count = 7 * 30,
+ diameters = diameter,
+ travel = diameter / (factor / 3),
+ periodAngle = 360f / 7
+ )
+ if (false) wavyCirclesPattern(
+ color = Color(0x28CA4FF7),
+ count = 5 * 30,
+ travel = diameter / (factor / 3),
+ periodAngle = 360f / 7,
+ diameters = diameter * .4f,
+ edgeMargin = diameter * 1.2f,
+// travel = diameter / factor
+ )
+ }
+}
+
+@MyPreview
+@Composable
+private fun TightCirclesPreview() = RootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ circlesPattern(color = gray, count = 180, diameters = diameter)
+ circlesPattern(color = blue.copy(alpha = .8f),count = 90, diameters = diameter, edgeMargin = diameter * 1.1f)
+ circlesPattern(color = lightGray.copy(alpha = .4f),count = 20, diameters = diameter, edgeMargin = diameter * 2.2f)
+ }
+}
+
+@MyPreview
+@Composable
+private fun TightCircles2Preview() = RootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ circlesPattern(color = lightGray, count = 180, diameters = diameter, edgeMargin = 5f)
+ circlesPattern(color = Color(0xFF7986CB).copy(alpha = .8f),count = 90, diameters = diameter, edgeMargin = diameter * 1.1f)
+ circlesPattern(color = gray.copy(alpha = .4f),count = 20, diameters = diameter, edgeMargin = diameter * 2.1f)
+ }
+}
+
+@MyPreview
+@Composable
+private fun TightCircles3Preview() = RootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ circlesPattern(color = lightGray, count = 90, diameters = diameter, edgeMargin = 5f)
+ circlesPattern(color = Color(0xFFC7A323).copy(alpha = .8f),count = 60, diameters = diameter, edgeMargin = diameter * 1.1f)
+ circlesPattern(color = gray.copy(alpha = .4f),count = 12, diameters = diameter, edgeMargin = diameter * 2.1f)
+ }
+}
+
+@MyPreview
+@Composable
+private fun TightCirclesTimePreview() = RootCanvas {
+ OClockCanvas {
+ val minutes = 35
+ val diameter = size.minDimension / 6
+ circlesPattern(
+ color = gray,
+ count = 180,
+ startAngle = 6f * minutes,
+ diameters = diameter
+ )
+ circlesPattern(
+ color = blue,
+ count = 180,
+ endAngle = 6f * minutes,
+ diameters = diameter
+ )
+ circlesPattern(
+ color = gray.copy(alpha = .8f),
+ count = 12,
+ diameters = diameter,
+ edgeMargin = diameter * 1.1f
+ )
+ circlesPattern(
+ color = lightGray.copy(alpha = .4f),
+ count = 20,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f
+ )
+ }
+}
+
+@MyPreview
+@Composable
+private fun TightCirclesPreviewAlpha() = RootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ circlesPattern(color = Color(0x66333333), count = 180, diameters = diameter)
+ circlesPattern(color = blue.copy(alpha = .8f),count = 90, diameters = diameter, edgeMargin = diameter * 1.1f)
+ circlesPattern(color = lightGray.copy(alpha = .4f),count = 20, diameters = diameter, edgeMargin = diameter * 2.2f)
+ }
+}
+
+@MyPreview
+@Composable
+private fun SpacedCircles() = RootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ circlesPattern(Color(0xFF64DD17), count = 31, diameters = diameter)
+ circlesPattern(color = Color(0xFF008800),count = 30, diameters = diameter, edgeMargin = diameter * 1.1f)
+ circlesPattern(color = Color(0xFFC6FF00),count = 12, diameters = diameter, edgeMargin = diameter * 2.2f)
+ }
+}
+
+@MyPreview
+@Composable
+private fun Rosace() = RootCanvas {
+ OClockCanvas {
+ val side = size.minDimension
+// circlesPattern(Color(0xFF64DD17), count = 31, diameters = diameter)
+// circlesPattern(color = Color(0xFF008800),count = 30, diameters = diameter, edgeMargin = diameter * 1.1f)
+ circlesPattern(
+ color = Color(0xFFFFAA16),
+ count = 12,
+ diameters = side / 2,
+ edgeMargin = side / 6
+ )
+ }
+}
+
+@MyPreview
+@Composable
+private fun Rosace2() = RootCanvas {
+ OClockCanvas {
+ val side = size.minDimension
+// circlesPattern(Color(0xFF64DD17), count = 31, diameters = diameter)
+// circlesPattern(color = Color(0xFF008800),count = 30, diameters = diameter, edgeMargin = diameter * 1.1f)
+ circlesPattern(
+ color = Color(0xFF64DD17),
+ count = 24,
+ diameters = side * .8f,
+ edgeMargin = side / 3
+ )
+ }
+}
+
+@MyPreview
+@Composable
+private fun Rosace3() = RootCanvas {
+ OClockCanvas {
+ val side = size.minDimension
+// circlesPattern(Color(0xFF64DD17), count = 31, diameters = diameter)
+// circlesPattern(color = Color(0xFF008800),count = 30, diameters = diameter, edgeMargin = diameter * 1.1f)
+ circlesPattern(
+ color = Color(0xFF64DD17),
+ count = 24,
+ diameters = side * .65f,
+ edgeMargin = side / 3
+ )
+ }
+}
+
+@MyPreview
+@Composable
+private fun TightOvalsPreview() = RootCanvas {
+ OClockCanvas {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = gray,
+ count = 180,
+ size = size
+ )
+ ovalPattern(
+ color = lightGray.copy(alpha = .4f),
+ count = 20,
+ size = size,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+}
+
+@MyPreview
+@Composable
+private fun TightOvalsPreviewAlpha() = RootCanvas {
+ OClockCanvas {
+ val side = size.minDimension
+ val size = Size(
+ width = side / 2,
+ height = side / 6
+ )
+ if (false )ovalPattern(
+ color = Color(0x66666666),
+ count = 180,
+ size = size,
+ edgeMargin = 10f,
+ )
+ ovalPattern(
+ color = Color(0xFFCDDC39),
+ count = 60,
+ size = Size(
+ width = side / 2,
+ height = side / 6
+ ) * 1.9f,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+}
+
+@MyPreview
+@Composable
+private fun TightOvalsPreviewAlpha2() = RootCanvas {
+ OClockCanvas {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66333333),
+ count = 180,
+ size = size
+ )
+ ovalPattern(
+ color = lightGray.copy(alpha = .4f),
+ count = 20,
+ size = size,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+}
+
+@MyPreview
+@Composable
+private fun TightOvalsPreviewAlt1() = RootCanvas {
+ OClockCanvas {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66333333),
+ count = 90,
+ size = size,
+ )
+ ovalPattern(
+ color = lightGray.copy(alpha = .4f),
+ count = 20,
+ size = size * 2f,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+}
+
+@MyPreview
+@Composable
+private fun TightOvalsPreviewAlt2() = RootCanvas {
+ OClockCanvas {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66333333),
+ count = 90,
+ size = size,
+ )
+ ovalPattern(
+ color = lightGray.copy(alpha = .4f),
+ count = 20,
+ size = size * 2f,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+}
+
+@MyPreview
+@Composable
+private fun TightOvalsPreviewAlt3() = RootCanvas {
+ OClockCanvas {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66333333),
+ count = 90,
+ size = size,
+ )
+ ovalPattern(
+ color = lightGray.copy(alpha = .4f),
+ count = 20,
+ size = size * 2f,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+}
+
+@Preview(
+ device = "id:wearos_large_round",
+ backgroundColor = 0xff000000,
+ showBackground = true,
+ group = "Devices - Small Round",
+ showSystemUi = true
+)
+private annotation class MyPreview
+
+
+@Composable
+private fun RootCanvas(
+ backgroundColor: Color = Color.Transparent,
+ content: @Composable (complicationData: Map>) -> Unit
+) = OClockRootCanvas(
+ modifier = Modifier.fillMaxSize(),
+ backgroundColor = backgroundColor,
+ content = content
+)
+
diff --git a/shared/src/main/kotlin/GoogleFonts.kt b/shared/src/main/kotlin/GoogleFonts.kt
new file mode 100644
index 0000000..a3e78b8
--- /dev/null
+++ b/shared/src/main/kotlin/GoogleFonts.kt
@@ -0,0 +1,10 @@
+package org.splitties.compose.oclock.sample
+
+import androidx.compose.ui.text.googlefonts.GoogleFont
+import com.louiscad.composeoclockplayground.shared.R
+
+val googleFontProvider = GoogleFont.Provider(
+ providerAuthority = "com.google.android.gms.fonts",
+ providerPackage = "com.google.android.gms",
+ certificates = R.array.com_google_android_gms_fonts_certs
+)
diff --git a/shared/src/main/kotlin/WatchFacePreview.kt b/shared/src/main/kotlin/WatchFacePreview.kt
new file mode 100644
index 0000000..650b6c7
--- /dev/null
+++ b/shared/src/main/kotlin/WatchFacePreview.kt
@@ -0,0 +1,92 @@
+package org.splitties.compose.oclock.sample
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.wear.watchface.complications.data.ComplicationData
+import kotlinx.coroutines.flow.*
+import org.splitties.compose.oclock.OClockRootCanvas
+
+class WearPreviewSizesProvider : PreviewParameterProvider {
+ override val values: Sequence = sequenceOf(
+ WatchFacePreview.Size.small,
+ WatchFacePreview.Size.large
+ )
+}
+
+/**
+ * Example usage:
+ *
+ * ```kotlin
+ * @WatchFacePreview
+ * @Composable
+ * private fun MyFunClockPreview(
+ * @PreviewParameter(WearPreviewSizesProvider::class) size: Dp
+ * ) = WatchFacePreview(size) {
+ * MyFunClock()
+ * }
+ * ```
+ */
+@Preview
+annotation class WatchFacePreview {
+ object Size {
+ val small = 192.dp
+ val large = 227.dp
+ }
+}
+
+@Composable
+fun WatchFacePreview(
+ size: Dp,
+ content: @Composable (complicationData: Map>) -> Unit
+) {
+ val spacing = 8.dp
+ var touched by remember { mutableStateOf(false) }
+ val isAmbientFlow = remember { MutableStateFlow(false) }
+ val isAmbient by isAmbientFlow.collectAsState()
+ val backgroundColor by animateColorAsState(
+ targetValue = when {
+ touched.not() -> Color.Transparent
+ isAmbient -> Color.DarkGray
+ else -> Color.LightGray
+ }
+ )
+ Column(
+ modifier = Modifier.background(backgroundColor).clickable {
+ touched = true
+ isAmbientFlow.update { it.not() }
+ }.padding(spacing),
+ verticalArrangement = Arrangement.spacedBy(spacing)
+ ) {
+ val modifier = Modifier.requiredSize(size).clip(CircleShape)
+ OClockRootCanvas(
+ modifier = modifier,
+ isAmbientFlow = isAmbientFlow
+ ) { content(it) }
+ AnimatedVisibility(touched.not()) {
+ OClockRootCanvas(
+ modifier = modifier,
+ isAmbientFlow = remember { MutableStateFlow(true) }
+ ) { content(it) }
+ }
+ }
+}
diff --git a/shared/src/main/kotlin/cleanthisbeforerelease/complications/PixelWatchStyleComplication.kt b/shared/src/main/kotlin/cleanthisbeforerelease/complications/PixelWatchStyleComplication.kt
new file mode 100644
index 0000000..20da976
--- /dev/null
+++ b/shared/src/main/kotlin/cleanthisbeforerelease/complications/PixelWatchStyleComplication.kt
@@ -0,0 +1,159 @@
+package org.splitties.compose.oclock.sample.cleanthisbeforerelease.complications
+
+import android.app.PendingIntent
+import android.os.Build
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.wear.watchface.complications.data.ComplicationData
+import androidx.wear.watchface.complications.data.EmptyComplicationData
+import androidx.wear.watchface.complications.data.GoalProgressComplicationData
+import androidx.wear.watchface.complications.data.LongTextComplicationData
+import androidx.wear.watchface.complications.data.MonochromaticImageComplicationData
+import androidx.wear.watchface.complications.data.NoDataComplicationData
+import androidx.wear.watchface.complications.data.NoPermissionComplicationData
+import androidx.wear.watchface.complications.data.NotConfiguredComplicationData
+import androidx.wear.watchface.complications.data.PhotoImageComplicationData
+import androidx.wear.watchface.complications.data.RangedValueComplicationData
+import androidx.wear.watchface.complications.data.ShortTextComplicationData
+import androidx.wear.watchface.complications.data.SmallImageComplicationData
+import androidx.wear.watchface.complications.data.WeightedElementsComplicationData
+import kotlinx.coroutines.flow.*
+import org.splitties.compose.oclock.LocalTime
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.complications.rememberDrawableAsState
+import org.splitties.compose.oclock.complications.rememberMeasuredAsState
+import org.splitties.compose.oclock.sample.extensions.topCenterAsTopLeft
+
+@Composable
+fun PixelWatchStyleComplication(
+ complicationDataFlow: StateFlow,
+ textStyle: TextStyle,
+ sizeRatio: Float
+) {
+ val time = LocalTime.current
+ val resources = LocalContext.current.resources
+ val complicationData by complicationDataFlow.collectAsState()
+ OClockCanvas(onTap = {
+ try {
+ complicationData.tapAction?.send()
+ } catch (e: PendingIntent.CanceledException) {
+ // In case the PendingIntent is no longer able to execute the request.
+ // We don't need to do anything here.
+ }
+ false
+ }) {}
+ when (val data = complicationData) {
+ is EmptyComplicationData -> {}
+ is NoDataComplicationData -> {
+ data.placeholder
+ data.contentDescription
+ }
+ is NoPermissionComplicationData -> {
+ data.rememberDrawableAsState()
+ data.text
+ data.title
+ }
+ is NotConfiguredComplicationData -> {}
+ is LongTextComplicationData -> {
+ val text by data.text.rememberMeasuredAsState { string ->
+ measure(text = string, style = textStyle)
+ }
+ val title by data.title.rememberMeasuredAsState("") { string ->
+ measure(text = string, style = textStyle)
+ }
+ val image by data.rememberDrawableAsState()
+ data.contentDescription
+ }
+ is MonochromaticImageComplicationData -> {
+ data.rememberDrawableAsState()
+ data.contentDescription
+ }
+ is PhotoImageComplicationData -> {
+ data.rememberDrawableAsState()
+ data.contentDescription
+ }
+ is RangedValueComplicationData -> {
+ data.valueType
+ RangedValueComplicationData.TYPE_RATING
+ RangedValueComplicationData.TYPE_PERCENTAGE
+ RangedValueComplicationData.TYPE_UNDEFINED
+ data.min
+ data.max
+ data.value
+ data.colorRamp
+ data.title
+ data.text
+ data.rememberDrawableAsState()
+ data.contentDescription
+ val text by data.text.rememberMeasuredAsState("") { string ->
+ measure(text = string, style = textStyle)
+ }
+ val title by data.title.rememberMeasuredAsState("") { string ->
+ measure(text = string, style = textStyle)
+ }
+ OClockCanvas {
+ drawArc(
+ color = textStyle.color,
+ startAngle = -90f,
+ sweepAngle = 360 * data.value / data.max,
+ useCenter = false,
+ style = Stroke(width = 10f, cap = StrokeCap.Round),
+ blendMode = BlendMode.Screen
+ )
+ drawText(
+ textLayoutResult = title,
+ topLeft = center.copy(
+ y = 20f
+ ).topCenterAsTopLeft(title.size)
+ )
+ drawText(
+ textLayoutResult = text,
+ topLeft = center.copy(
+ y = 20f - title.size.height
+ ).topCenterAsTopLeft(text.size)
+ )
+ }
+ }
+ is ShortTextComplicationData -> {
+ data.rememberDrawableAsState()
+ data.text
+ data.title
+ data.contentDescription
+ }
+ is SmallImageComplicationData -> {
+ data.rememberDrawableAsState()
+ data.contentDescription
+ }
+ else -> if (Build.VERSION.SDK_INT >= 33) when (data) {
+ is GoalProgressComplicationData -> {
+ data.targetValue
+ data.value
+ data.value
+ data.colorRamp
+ data.text
+ data.title
+ data.rememberDrawableAsState()
+ data.contentDescription
+ }
+ is WeightedElementsComplicationData -> {
+ data.elements.forEach {
+ it.color
+ it.weight
+ }
+ data.elementBackgroundColor
+ data.rememberDrawableAsState()
+ data.title
+ data.text
+ data.contentDescription
+ }
+ else -> {}
+ }
+ }
+}
diff --git a/shared/src/main/kotlin/cleanthisbeforerelease/elements/CirclePatterns.kt b/shared/src/main/kotlin/cleanthisbeforerelease/elements/CirclePatterns.kt
new file mode 100644
index 0000000..a9285ee
--- /dev/null
+++ b/shared/src/main/kotlin/cleanthisbeforerelease/elements/CirclePatterns.kt
@@ -0,0 +1,120 @@
+package org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements
+
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.unit.dp
+import org.splitties.compose.oclock.sample.extensions.drawOval
+import org.splitties.compose.oclock.sample.extensions.rotate
+import kotlin.math.PI
+import kotlin.math.sin
+
+fun DrawScope.circlesPattern(
+ color: Color = Color.White.copy(alpha = .4f),
+ count: Int = 90,
+ startAngle: Float = 0f,
+ endAngle: Float = 360f,
+ diameters: Float,
+ edgeMargin: Float = 0f,
+) {
+ val stroke = Stroke(width = 1.dp.toPx())
+ val radius = diameters / 2
+ for (i in 1..count) {
+ val angle = 360f * i / count
+ if (angle < startAngle) continue
+ if (angle > endAngle) return
+ drawCircle(
+ color = color,
+ style = stroke,
+ radius = radius,
+ center = center.copy(y = edgeMargin + stroke.width / 2 + radius).rotate(angle)
+ )
+ }
+}
+
+fun DrawScope.wavyCirclesPattern(
+ color: Color = Color.White.copy(alpha = .4f),
+ count: Int = 90,
+ startAngle: Float = 0f,
+ endAngle: Float = 360f,
+ diameters: Float,
+ travel: Float = diameters / 2f,
+ periodAngle: Float = 30f,
+ edgeMargin: Float = 0f,
+) {
+ val stroke = Stroke(width = 1.dp.toPx())
+ val radius = diameters / 2
+ for (i in 1..count) {
+ val angle = 360f * i / count
+ if (angle < startAngle) continue
+ if (angle > endAngle) return
+ val periodicOffset = (travel * (sin(2 * PI * (angle % periodAngle) / periodAngle - PI / 2) + 1)) / 2
+ val y = edgeMargin + stroke.width / 2 + radius + periodicOffset.toFloat()
+ drawCircle(
+ color = color,
+ style = stroke,
+ radius = radius,
+ center = center.copy(y = y).rotate(angle)
+ )
+ }
+}
+
+fun DrawScope.circlesPattern(
+ color: Color = Color.White.copy(alpha = .4f),
+ count: Int = 90,
+ diameters: List,
+ edgeMargin: Float = 0f,
+) {
+ val stroke = Stroke(width = 1.dp.toPx())
+ diameters.forEach { diameter ->
+ val radius = diameter / 2
+ repeat(count) { i ->
+ val angle = 360f * i / count
+ drawCircle(
+ color = color,
+ style = stroke,
+ radius = radius,
+ center = center.copy(y = edgeMargin + stroke.width / 2 + radius).rotate(angle)
+ )
+ }
+ }
+}
+
+fun DrawScope.ovalPattern(
+ color: Color = Color.White.copy(alpha = .4f),
+ count: Int = 90,
+ size: Size,
+ edgeMargin: Float = 0f,
+) {
+ val stroke = Stroke(width = 1.dp.toPx())
+ repeat(count) { i ->
+ rotate(degrees = 360f * i / count) {
+ drawOval(
+ color = color,
+ style = stroke,
+ size = size,
+ center = center.copy(y = edgeMargin + stroke.width / 2 + size.height / 2)
+ )
+ }
+ }
+}
+
+fun DrawScope.straightOvalPattern(
+ color: Color = Color.White.copy(alpha = .4f),
+ count: Int = 90,
+ size: Size,
+ edgeMargin: Float = 0f,
+) {
+ val stroke = Stroke(width = 1.dp.toPx())
+ repeat(count) { i ->
+ drawOval(
+ color = color,
+ style = stroke,
+ size = size,
+ center = center.copy(
+ y = edgeMargin + stroke.width / 2 + size.height / 2
+ ).rotate(degrees = 360f * i / count)
+ )
+ }
+}
diff --git a/shared/src/main/kotlin/cleanthisbeforerelease/elements/LinePatterns.kt b/shared/src/main/kotlin/cleanthisbeforerelease/elements/LinePatterns.kt
new file mode 100644
index 0000000..3c06577
--- /dev/null
+++ b/shared/src/main/kotlin/cleanthisbeforerelease/elements/LinePatterns.kt
@@ -0,0 +1,18 @@
+package org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.unit.dp
+import org.splitties.compose.oclock.sample.extensions.rotate
+
+fun DrawScope.linesPattern(
+ color: Color = Color.White.copy(alpha = .4f),
+ count: Int = 90
+) = circularRepeat(count) { degrees ->
+ drawLine(
+ color = color,
+ strokeWidth = 1.dp.toPx(),
+ start = center.copy(y = 0f).rotate(degrees = degrees),
+ end = center.copy(y = 50.dp.toPx()).rotate(degrees = degrees),
+ )
+}
diff --git a/shared/src/main/kotlin/cleanthisbeforerelease/elements/PatternHelpers.kt b/shared/src/main/kotlin/cleanthisbeforerelease/elements/PatternHelpers.kt
new file mode 100644
index 0000000..83ac1fa
--- /dev/null
+++ b/shared/src/main/kotlin/cleanthisbeforerelease/elements/PatternHelpers.kt
@@ -0,0 +1,10 @@
+package org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements
+
+inline fun circularRepeat(
+ count: Int,
+ block: (angleInDegrees: Float) -> Unit
+) {
+ repeat(count) { i ->
+ block(360f * i / count)
+ }
+}
diff --git a/shared/src/main/kotlin/cleanthisbeforerelease/elements/PatternsPreviews.kt b/shared/src/main/kotlin/cleanthisbeforerelease/elements/PatternsPreviews.kt
new file mode 100644
index 0000000..ccb0516
--- /dev/null
+++ b/shared/src/main/kotlin/cleanthisbeforerelease/elements/PatternsPreviews.kt
@@ -0,0 +1,443 @@
+package org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.ui.tooling.preview.WearPreviewSmallRound
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.OClockRootCanvas
+import org.splitties.compose.oclock.sample.extensions.rotate
+
+@WearPreviewSmallRound
+@Composable
+private fun MultiCirclesPreview() = OClockRootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 2
+ circlesPattern(
+ color = Color(0xff666666),
+ count = 6,
+ diameters = listOf(.1, .15, .2, .25).map { it.toFloat() * diameter }
+ )
+ rotate(30f) {
+ circlesPattern(
+ color = Color(0xff666666),
+ count = 6,
+ diameters = listOf(.1, .15, .2, .25).map { it.toFloat() * diameter * 2f }
+ )
+ }
+ /*circlesPattern(
+ color = Color(0xFF0091EA).copy(alpha = .8f),
+ count = 90,
+ diameters = diameter,
+ edgeMargin = diameter * 1.1f
+ )
+ circlesPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f
+ )*/
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun WavyCircles1Preview() = OClockRootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ wavyCirclesPattern(color = Color(0xff666666), count = 180, diameters = diameter)
+ wavyCirclesPattern(
+ color = Color(0xFF0091EA).copy(alpha = .8f),
+ count = 90,
+ diameters = diameter,
+ edgeMargin = diameter * 1.1f
+ )
+ wavyCirclesPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun WavyCircles2Preview() = OClockRootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ val factor = 6f
+ wavyCirclesPattern(
+ color = Color(0xFF0091EA).copy(alpha = .8f),
+ count = 90,
+ diameters = diameter,
+ edgeMargin = diameter * 1.2f,
+ travel = diameter / 2
+ )
+ wavyCirclesPattern(
+ color = Color(0xff666666),
+ count = 180,
+ diameters = diameter,
+ travel = diameter / factor,
+ periodAngle = 15f
+ )
+ circlesPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 8,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f,
+// travel = diameter / factor
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun WavyCircles3Preview() = OClockRootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ val factor = 6f
+ wavyCirclesPattern(
+ color = Color(0xFF0091EA).copy(alpha = .8f),
+ count = 90,
+ diameters = diameter,
+ edgeMargin = diameter * 1.2f,
+ travel = diameter / 2
+ )
+ wavyCirclesPattern(
+ color = Color(0xff666666),
+ count = 180,
+ diameters = diameter,
+ travel = diameter / (factor / 3),
+ periodAngle = 60f
+ )
+ circlesPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 8,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f,
+// travel = diameter / factor
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun TightCirclesPreview() = OClockRootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ circlesPattern(color = Color(0xff666666), count = 180, diameters = diameter)
+ circlesPattern(
+ color = Color(0xFF0091EA).copy(alpha = .8f),
+ count = 90,
+ diameters = diameter,
+ edgeMargin = diameter * 1.1f
+ )
+ circlesPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun TightCirclesTimePreview() = OClockRootCanvas {
+ OClockCanvas {
+ val minutes = 35
+ val diameter = size.minDimension / 6
+ circlesPattern(
+ color = Color(0xff666666),
+ count = 180,
+ startAngle = 6f * minutes,
+ diameters = diameter
+ )
+ circlesPattern(
+ color = Color(0xFF0091EA),
+ count = 180,
+ endAngle = 6f * minutes,
+ diameters = diameter
+ )
+ circlesPattern(
+ color = Color(0xff666666).copy(alpha = .8f),
+ count = 12,
+ diameters = diameter,
+ edgeMargin = diameter * 1.1f
+ )
+ circlesPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun TightCirclesPreviewAlpha() = OClockRootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ circlesPattern(color = Color(0x66ffffff), count = 180, diameters = diameter)
+ circlesPattern(
+ color = Color(0xFF0091EA).copy(alpha = .8f),
+ count = 90,
+ diameters = diameter,
+ edgeMargin = diameter * 1.1f
+ )
+ circlesPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun SpacedCircles() = OClockRootCanvas {
+ OClockCanvas {
+ val diameter = size.minDimension / 6
+ circlesPattern(Color(0xFF64DD17), count = 31, diameters = diameter)
+ circlesPattern(
+ color = Color(0xFF008800),
+ count = 30,
+ diameters = diameter,
+ edgeMargin = diameter * 1.1f
+ )
+ circlesPattern(
+ color = Color(0xFFC6FF00),
+ count = 12,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun TightOvalsPreview() = OClockRootCanvas {
+ OClockCanvas {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0xff666666),
+ count = 180,
+ size = size
+ )
+ ovalPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ size = size,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun TightOvalsPreviewAlpha() = OClockRootCanvas {
+ OClockCanvas {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66666666),
+ count = 180,
+ size = size
+ )
+ ovalPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ size = size,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun TightOvalsPreviewAlpha2() = OClockRootCanvas {
+ OClockCanvas {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66ffffff),
+ count = 180,
+ size = size
+ )
+ ovalPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ size = size,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun TightOvalsPreviewAlt1() = OClockRootCanvas {
+ OClockCanvas {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66ffffff),
+ count = 90,
+ size = size,
+ )
+ ovalPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ size = size * 2f,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun TightOvalsPreviewAlt2() = OClockRootCanvas {
+ OClockCanvas {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66666666),
+ count = 90,
+ size = size,
+ )
+ ovalPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ size = size * 2f,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun TightOvalsPreviewAlt3() = OClockRootCanvas {
+ OClockCanvas {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66ffffff),
+ count = 90,
+ size = size,
+ )
+ ovalPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ size = size * 2f,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun JustLines() = OClockRootCanvas {
+ OClockCanvas {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ linesPattern(
+ color = Color(0x66ffffff),
+ count = 90,
+ )
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun Lines1() = OClockRootCanvas {
+ OClockCanvas {
+ val strokeWidth = 1.dp.toPx()
+ val offset = 20.dp.toPx()
+ run {
+ val start = center.run { copy(x = x - offset, y = 0f) }
+ val end = center.run { copy(x = x + offset, y = y - offset) }
+ circularRepeat(60) { degrees ->
+ drawLine(
+ color = Color(0x66ffffff),
+ strokeWidth = strokeWidth,
+ start = start.rotate(degrees),
+ end = end.rotate(degrees)
+ )
+ }
+ }
+ run {
+ val start = center.run { copy(x = x + offset, y = 0f) }
+ val end = center.run { copy(x = x + offset, y = y - offset) }
+ circularRepeat(60) { degrees ->
+ drawLine(
+ color = Color(0x66ffffff),
+ strokeWidth = strokeWidth,
+ start = start.rotate(degrees),
+ end = end.rotate(degrees)
+ )
+ }
+ }
+ }
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun Lines2() = OClockRootCanvas {
+ OClockCanvas {
+// val color = Color(0x66ffffff)
+ val color = Color(0xff666666)
+ val strokeWidth = 1.dp.toPx()
+ val offset = 50.dp.toPx()
+// run {
+// val start = center.run { copy(x = x - offset, y = 0f) }
+// val end = center.run { copy(x = x + offset, y = y - offset) }
+// circularRepeat(60) { degrees ->
+// drawLine(
+// color = color,
+// strokeWidth = strokeWidth,
+// start = start.rotate(degrees),
+// end = end.rotate(degrees)
+// )
+// }
+// }
+// run {
+// val start = center.run { copy(x = x + offset, y = 0f) }
+// val end = center.run { copy(x = x - offset, y = y - offset) }
+// circularRepeat(60) { degrees ->
+// drawLine(
+// color = color,
+// strokeWidth = strokeWidth,
+// start = start.rotate(degrees),
+// end = end.rotate(degrees)
+// )
+// }
+// }
+ val radius = size.minDimension / 6f
+ circularRepeat(60) { degrees ->
+ drawLine(
+ color = color,
+ strokeWidth = strokeWidth,
+ start = center.run { copy(x = x - offset, y = 0f) }.rotate(degrees),
+ end = center.run { copy(x = x - offset, y = size.minDimension) }.rotate(degrees)
+ )
+ }
+ }
+}
diff --git a/shared/src/main/kotlin/cleanthisbeforerelease/experiments/CirclesExperiment.kt b/shared/src/main/kotlin/cleanthisbeforerelease/experiments/CirclesExperiment.kt
new file mode 100644
index 0000000..f3af0cf
--- /dev/null
+++ b/shared/src/main/kotlin/cleanthisbeforerelease/experiments/CirclesExperiment.kt
@@ -0,0 +1,214 @@
+package org.splitties.compose.oclock.sample.cleanthisbeforerelease.experiments
+
+import android.graphics.RenderNode
+import android.os.Build.VERSION.SDK_INT
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.ui.tooling.preview.WearPreviewSmallRound
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.OClockRootCanvas
+import org.splitties.compose.oclock.TapEvent
+import org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements.circlesPattern
+import org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements.ovalPattern
+
+@Composable
+fun CirclesExperiment() {
+ var index by remember { mutableIntStateOf(1) }
+ OClockCanvas(
+ onTap = { event ->
+ val squaredMaxDistance = 48f.squared().dp.toPx()
+ val squaredDistanceTo = event.position.squaredDistanceTo(center)
+ if (squaredDistanceTo <= squaredMaxDistance) {
+ if (event is TapEvent.Up) index++
+ true
+ } else false
+ }
+ ) {
+ if (SDK_INT >= 29) {
+ val node = RenderNode("")
+ val recordingCanvas = node.beginRecording(size.width.toInt(), size.height.toInt())
+ recordingCanvas
+ drawIntoCanvas { it.nativeCanvas.drawRenderNode(node) }
+
+ }
+ when (index) {
+ 0 -> {}
+ 1 -> {
+ val minutes = 35
+ val diameter = size.minDimension / 6
+ circlesPattern(
+ color = Color(0xff666666),
+ count = 180,
+ startAngle = 6f * minutes,
+ diameters = diameter
+ )
+ circlesPattern(
+ color = Color(0xFF0091EA),
+ count = 180,
+ endAngle = 6f * minutes,
+ diameters = diameter
+ )
+ circlesPattern(
+ color = Color(0xff666666).copy(alpha = .8f),
+ count = 12,
+ diameters = diameter,
+ edgeMargin = diameter * 1.1f
+ )
+ circlesPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ diameters = diameter,
+ edgeMargin = diameter * 2.2f
+ )
+ }
+ 2 -> {
+ val diameter = size.minDimension / 6
+ circlesPattern(color = Color(0xff666666), count = 180, diameters = diameter)
+ circlesPattern(color = Color(0xFF0091EA).copy(alpha = .8f),count = 90, diameters = diameter, edgeMargin = diameter * 1.1f)
+ circlesPattern(color = Color.White.copy(alpha = .4f),count = 20, diameters = diameter, edgeMargin = diameter * 2.2f)
+ }
+ 3 -> {
+ val diameter = size.minDimension / 6
+ circlesPattern(color = Color(0x66ffffff), count = 180, diameters = diameter)
+ circlesPattern(color = Color(0xFF0091EA).copy(alpha = .8f),count = 90, diameters = diameter, edgeMargin = diameter * 1.1f)
+ circlesPattern(color = Color.White.copy(alpha = .4f),count = 20, diameters = diameter, edgeMargin = diameter * 2.2f)
+ }
+ 4 -> {
+ drawCircle(Color.Gray)
+ }
+ 5 -> {
+ val diameter = size.minDimension / 6
+ circlesPattern(Color(0xFF64DD17), count = 31, diameters = diameter)
+ circlesPattern(color = Color(0xFF008800),count = 30, diameters = diameter, edgeMargin = diameter * 1.1f)
+ circlesPattern(color = Color(0xFFC6FF00),count = 12, diameters = diameter, edgeMargin = diameter * 2.2f)
+ }
+ 6 -> {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0xff666666),
+ count = 180,
+ size = size
+ )
+ ovalPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ size = size,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+ 7 -> {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66666666),
+ count = 180,
+ size = size
+ )
+ ovalPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ size = size,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+ 8 -> {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66ffffff),
+ count = 180,
+ size = size
+ )
+ ovalPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ size = size,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+ 9 -> {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66ffffff),
+ count = 90,
+ size = size,
+ )
+ ovalPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ size = size * 2f,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+ 10 -> {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66666666),
+ count = 90,
+ size = size,
+ )
+ ovalPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ size = size * 2f,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+ 11 -> {
+ val size = Size(
+ width = size.minDimension / 2,
+ height = size.minDimension / 6
+ )
+ ovalPattern(
+ color = Color(0x66ffffff),
+ count = 90,
+ size = size,
+ )
+ ovalPattern(
+ color = Color.White.copy(alpha = .4f),
+ count = 20,
+ size = size * 2f,
+ edgeMargin = size.height * 2.2f
+ )
+ }
+ else -> index = 0
+ }
+ drawIntoCanvas {
+ println("hardwareAccelerated: ${it.nativeCanvas.isHardwareAccelerated}")
+ }
+ }
+}
+
+private fun Float.squared() = this * this
+
+private fun Offset.squaredDistanceTo(other: Offset): Float {
+ return (other.x - x).squared() + (other.y - y).squared()
+}
+
+@WearPreviewSmallRound
+@Composable
+private fun CirclesExperimentPreview() = OClockRootCanvas {
+ CirclesExperiment()
+}
diff --git a/shared/src/main/kotlin/cleanthisbeforerelease/experiments/KotlinLogoExperiments.kt b/shared/src/main/kotlin/cleanthisbeforerelease/experiments/KotlinLogoExperiments.kt
new file mode 100644
index 0000000..adada5c
--- /dev/null
+++ b/shared/src/main/kotlin/cleanthisbeforerelease/experiments/KotlinLogoExperiments.kt
@@ -0,0 +1,289 @@
+package org.splitties.compose.oclock.sample.cleanthisbeforerelease.experiments
+
+import androidx.annotation.FloatRange
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.drawscope.Fill
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.unit.dp
+import org.splitties.compose.oclock.LocalIsAmbient
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.sample.WatchFacePreview
+import org.splitties.compose.oclock.sample.cleanthisbeforerelease.elements.circularRepeat
+import org.splitties.compose.oclock.sample.extensions.centerAsTopLeft
+import org.splitties.compose.oclock.sample.extensions.cubicTo
+import org.splitties.compose.oclock.sample.extensions.lineTo
+import org.splitties.compose.oclock.sample.extensions.moveTo
+import org.splitties.compose.oclock.sample.extensions.offsetBy
+import org.splitties.compose.oclock.sample.extensions.plus
+import org.splitties.compose.oclock.sample.extensions.quadraticBezierTo
+import org.splitties.compose.oclock.sample.extensions.rememberStateWithSize
+import org.splitties.compose.oclock.sample.extensions.rotate
+import org.splitties.compose.oclock.sample.extensions.rotateAround
+import kotlin.math.sqrt
+
+@WatchFacePreview
+@Composable
+private fun KotlinLogoExperimentsPreview() = WatchFacePreview(
+ WatchFacePreview.Size.small
+) {
+ KotlinLogoExperiments()
+}
+
+@Composable
+fun KotlinLogoExperiments() {
+ val smallKotlinLogoPath = remember { Path() }
+ val bigKotlinLogoPath = remember { Path() }
+ val edgePadding = 0.dp
+ val isAmbient by LocalIsAmbient.current
+ val cachedBigStroke = rememberStateWithSize { Stroke(4f.dp.toPx()) }
+ val cachedBigLogoGradient = rememberStateWithSize {
+ val bigStroke by cachedBigStroke
+ val side = size.minDimension / 3f
+ val topLeft = center.centerAsTopLeft(side)
+ bigKotlinLogoPath.setToKotlinLogo(
+ side,
+ topLeft,
+ stroke = if (isAmbient) bigStroke else null
+ )
+ Brush.linearGradient(
+ kotlinLogoColors,
+ start = topLeft.run { copy(y = y + side) },
+ end = topLeft.run { copy(x = x + side) }
+ )
+ }
+ val cachedSmallLogosGradient = rememberStateWithSize {
+ val side = size.minDimension / 8f
+ val topLeft = Offset(x = center.x, y = side / 2f + edgePadding.toPx()).centerAsTopLeft(side)
+ smallKotlinLogoPath.setToStar4(
+ side = side,
+// size = Size(side, side),
+// thickness = side / 5f,
+ topLeft = topLeft
+ )
+ Brush.linearGradient(
+ kotlinLogoColors,
+ start = topLeft.run { copy(y = y + side) },
+ end = topLeft.run { copy(x = x + side) }
+ )
+ }
+ val cachedStroke = rememberStateWithSize {
+ Stroke(
+ 1.5f.dp.toPx(),
+ cap = StrokeCap.Butt,
+ join = StrokeJoin.Miter
+ )
+ Stroke(1.5f.dp.toPx(), cap = StrokeCap.Butt, join = StrokeJoin.Miter)
+ }
+ OClockCanvas {
+// if (isAmbient.not()) {
+// drawRect(kotlinDarkBg)
+// }
+ cachedSmallLogosGradient.get()
+ val stroke by cachedStroke
+ val bigStroke by cachedBigStroke
+ val smallLogosGradient by cachedSmallLogosGradient
+ val bigLogosGradient by cachedBigLogoGradient
+ circularRepeat(24) {
+ rotate(it) {
+ drawPath(
+ smallKotlinLogoPath,
+ color = Color.Cyan,
+ style = stroke
+ )
+ }
+ }
+ drawPath(
+ bigKotlinLogoPath,
+ brush = bigLogosGradient,
+ style = if (isAmbient) bigStroke else Fill
+ )
+ drawCircle(
+ brush = bigLogosGradient,
+ blendMode = BlendMode.SrcIn
+ )
+ }
+}
+
+private val kotlinDarkBg = Color(0xFF1B1B1B)
+private val kotlinBlue = Color(0xFF7F52FF)
+private val kotlinLogoColors = listOf(
+ kotlinBlue,
+ Color(0xFF_C811E2),
+ Color(0xFF_E54857),
+)
+
+private fun Path.setToLLetter(
+ side: Float,
+ smallSide: Float = side * .6f,
+ thickness: Float,
+ topLeft: Offset = Offset.Zero,
+) {
+ if (isEmpty.not()) reset()
+ moveTo(topLeft)
+ lineTo(topLeft.offsetBy(x = thickness))
+ lineTo(topLeft.offsetBy(x = thickness, y = side - thickness))
+ lineTo(topLeft.offsetBy(x = smallSide, y = side - thickness))
+ lineTo(topLeft.offsetBy(x = smallSide, y = side))
+ lineTo(topLeft.offsetBy(y = side))
+ close()
+}
+
+private fun Path.setToStar4(
+ side: Float,
+ topLeft: Offset = Offset.Zero,
+) {
+ setToStarX(count = 4, side = side, topLeft = topLeft)
+}
+
+private fun Path.setToStarX(
+ count: Int,
+ side: Float,
+ topLeft: Offset = Offset.Zero,
+) {
+ if (isEmpty.not()) reset()
+ val half = side / 2f
+ val topMiddle = topLeft.offsetBy(x = half)
+ val center = topLeft + half
+ moveTo(topMiddle)
+ for (i in 1..count) {
+ val target = topMiddle.rotateAround(pivot = center, degrees = 360f * i / count)
+ quadraticBezierTo(center, target)
+ }
+ close()
+}
+
+private fun Path.setToHeart(
+ topLeft: Offset = Offset.Zero,
+ size: Size,
+ @FloatRange(.0, 1.65) tipSharpnessRatio: Float = 1.1f
+) {
+ if (isEmpty.not()) reset()
+ val halfH = size.height / 2f
+ val halfW = size.width / 2f
+ val topControl = halfH * .68f
+ val tipControl = halfH * tipSharpnessRatio
+ val topMiddle = topLeft.offsetBy(
+ x = halfW,
+ y = halfW * .5f
+ )
+ moveTo(topMiddle)
+ cubicTo(
+ topMiddle.offsetBy(y = -topControl),
+ topMiddle.offsetBy(x = -halfW, y = -topControl),
+ topMiddle.offsetBy(x = -halfW) // Left
+ )
+ arcTo(Rect(topMiddle, topMiddle.offsetBy(x = -halfW)), startAngleDegrees = 0f, -180f, forceMoveTo = false)
+ cubicTo(
+ topMiddle.offsetBy(x = -halfW, y = topControl),
+ topMiddle.offsetBy(y = tipControl),
+ topMiddle.copy(y = topLeft.y + size.height) // Bottom
+ )
+ cubicTo(
+ topMiddle.offsetBy(y = tipControl),
+ topMiddle.offsetBy(x = halfW, y = topControl),
+ topMiddle.offsetBy(x = halfW) // Right
+ )
+ cubicTo(
+ topMiddle.offsetBy(x = halfW, y = -topControl),
+ topMiddle.offsetBy(y = -topControl),
+ topMiddle // Top
+ )
+ close()
+}
+
+private fun Path.setToSketchyHeart(
+ size: Size,
+ topLeft: Offset = Offset.Zero,
+) {
+ if (isEmpty.not()) reset()
+ val halfH = size.height / 2f
+ val halfW = size.width / 2f
+ val circlesRadius = size.width / 4f
+ val topPoint = topLeft.offsetBy(x = halfW, y = circlesRadius)
+ val bottomMiddle = topLeft.offsetBy(x = halfW, y = size.height)
+ moveTo(bottomMiddle)
+ val bottomControl = topLeft.offsetBy(x = halfW, y = size.height * .8f)
+ val leftEdge = topLeft.offsetBy(y = size.height / 4)
+ val rightEdge = topLeft.offsetBy(x = size.width,y = size.height / 4)
+ cubicTo(
+ cp1 = bottomMiddle,
+ cp2 = bottomControl,
+ point = bottomMiddle
+ )
+ cubicTo(
+ cp1 = leftEdge.offsetBy(y = circlesRadius),
+ cp2 = leftEdge.offsetBy(y = - circlesRadius),
+ point = leftEdge
+ )
+ val topMiddle = topLeft.offsetBy(x = halfW)
+ val topLeftEdge = topMiddle.offsetBy(x = -circlesRadius)
+ val topRightEdge = topMiddle.offsetBy(x = circlesRadius)
+ cubicTo(
+ cp1 = topLeft,
+ cp2 = topLeft.offsetBy(x = halfW),
+ point = topLeftEdge
+ )
+ cubicTo(
+ cp1 = topMiddle,
+ cp2 = topPoint,
+ point = topPoint
+ )
+ cubicTo(
+ cp1 = topPoint,
+ cp2 = topMiddle,
+ point = topRightEdge
+ )
+ cubicTo(
+ cp1 = rightEdge.offsetBy(y = -circlesRadius),
+ cp2 = rightEdge.offsetBy(y = circlesRadius),
+ point = rightEdge
+ )
+ cubicTo(
+ cp1 = bottomControl,
+ cp2 = bottomMiddle,
+ point = bottomMiddle
+ )
+ close()
+}
+
+private fun Path.setToKotlinLogo(
+ side: Float,
+ topLeft: Offset = Offset.Zero,
+) {
+ if (isEmpty.not()) reset()
+ moveTo(topLeft)
+ lineTo(topLeft.offsetBy(x = side))
+ lineTo(topLeft.offsetBy(x = side / 2f, y = side / 2f))
+ lineTo(topLeft.offsetBy(x = side, y = side))
+ lineTo(topLeft.offsetBy(y = side))
+ close()
+}
+
+private fun Path.setToKotlinLogo(
+ side: Float,
+ topLeft: Offset = Offset.Zero,
+ stroke: Stroke? = null,
+) {
+ val strokeWidth = stroke?.width ?: 0f
+ val halfStroke = strokeWidth / 2f
+ val endOffset = if (stroke != null) sqrt(halfStroke * halfStroke * 2) else 0f
+ if (isEmpty.not()) reset()
+ val logoCenterX = side / 2f - endOffset
+ moveTo(topLeft + halfStroke)
+ lineTo(topLeft.offsetBy(x = side - endOffset - halfStroke, y = halfStroke))
+ lineTo(topLeft.offsetBy(x = logoCenterX, y = side / 2f))
+ lineTo(topLeft.offsetBy(x = side - endOffset - halfStroke, y = side - halfStroke))
+ lineTo(topLeft.offsetBy(x = halfStroke, y = side - halfStroke))
+ close()
+}
diff --git a/shared/src/main/kotlin/cleanthisbeforerelease/experiments/ShadersExperiments.kt b/shared/src/main/kotlin/cleanthisbeforerelease/experiments/ShadersExperiments.kt
new file mode 100644
index 0000000..5412510
--- /dev/null
+++ b/shared/src/main/kotlin/cleanthisbeforerelease/experiments/ShadersExperiments.kt
@@ -0,0 +1,437 @@
+package org.splitties.compose.oclock.sample.cleanthisbeforerelease.experiments
+
+import android.graphics.RuntimeShader
+import androidx.annotation.RequiresApi
+import androidx.compose.animation.core.withInfiniteAnimationFrameMillis
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.ShaderBrush
+import androidx.wear.compose.ui.tooling.preview.WearPreviewLargeRound
+import org.intellij.lang.annotations.Language
+import org.splitties.compose.oclock.LocalIsAmbient
+import org.splitties.compose.oclock.LocalTime
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.OClockRootCanvas
+import org.splitties.compose.oclock.sample.extensions.rememberStateWithSize
+
+@RequiresApi(33)
+@Composable
+fun ShaderExperiments(
+ shader: RuntimeShader = remember { createSkiaExampleShader1() },
+ hideInAmbient: Boolean = true
+) {
+ val brush = remember(shader) { ShaderBrush(shader) }
+ val shaderUpdater = rememberStateWithSize(shader) {
+ shader.setFloatUniform("iResolution", size.width, size.height)
+ }
+ val time = LocalTime.current
+ val isAmbient by LocalIsAmbient.current
+ if (isAmbient && hideInAmbient) return
+ val t by produceDrawLoopCounter(1f)
+ OClockCanvas {
+ shaderUpdater.get()
+ shader.setFloatUniform("iTime", t)
+ drawRect(brush)
+ }
+}
+
+
+@RequiresApi(33)
+fun createWarpSpeedShader() = RuntimeShader(
+ """
+ // 'Warp Speed 2'
+ // David Hoskins 2015.
+ // License Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
+
+ // Fork of:- https://www.shadertoy.com/view/Msl3WH
+ //----------------------------------------------------------------------------------------
+ uniform float2 iResolution; // Viewport resolution (pixels)
+ uniform float iTime; // Shader playback time (s)
+
+ vec4 main( in float2 fragCoord )
+ {
+ float s = 0.0, v = 0.0;
+ vec2 uv = (fragCoord / iResolution.xy) * 2.0 - 1.;
+ float iTime = (iTime-2.0)*58.0;
+ vec3 col = vec3(0);
+ vec3 init = vec3(sin(iTime * .0032)*.3, .35 - cos(iTime * .005)*.3, iTime * 0.002);
+ for (int r = 0; r < 100; r++)
+ {
+ vec3 p = init + s * vec3(uv, 0.05);
+ p.z = fract(p.z);
+ // Thanks to Kali's little chaotic loop...
+ for (int i=0; i < 10; i++) p = abs(p * 2.04) / dot(p, p) - .9;
+ v += pow(dot(p, p), .7) * .06;
+ col += vec3(v * 0.2+.4, 12.-s*2., .1 + v * 1.) * v * 0.00003;
+ s += .025;
+ }
+ return vec4(clamp(col, 0.0, 1.0), 1.0);
+ }
+ """.trimIndent()
+)
+
+@RequiresApi(33)
+fun createSkiaExampleShader1() = RuntimeShader(
+ """
+ //----------------------------------------------------------------------------------------
+ uniform float2 iResolution; // Viewport resolution (pixels)
+ uniform float iTime; // Shader playback time (s)
+
+ // Source: @notargs https://twitter.com/notargs/status/1250468645030858753
+ float f(vec3 p) {
+ p.z -= iTime * 10.;
+ float a = p.z * .1;
+ p.xy *= mat2(cos(a), sin(a), -sin(a), cos(a));
+ return .1 - length(cos(p.xy) + sin(p.yz));
+ }
+
+ half4 main(vec2 fragcoord) {
+ vec3 d = .5 - fragcoord.xy1 / iResolution.y;
+ vec3 p=vec3(0);
+ for (int i = 0; i < 32; i++) {
+ p += f(p) * d;
+ }
+ return ((sin(p) + vec3(2, 5, 0)) / length(p)).xyz1;
+ }
+
+ """.trimIndent()
+)
+
+@RequiresApi(33)
+fun createSeascapeShader() = RuntimeShader(
+ """
+ //----------------------------------------------------------------------------------------
+ uniform float2 iResolution; // Viewport resolution (pixels)
+ uniform float iTime; // Shader playback time (s)
+
+ /*
+ * "Seascape" by Alexander Alekseev aka TDM - 2014
+ * License Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
+ * Contact: tdmaav@gmail.com
+ */
+
+ const int NUM_STEPS = 8;
+ const float2 iMouse = float2(150.0, 150.0);
+ const float PI = 3.141592;
+ const float EPSILON = 1e-3;
+// const float EPSILON_NRM = (0.1 / iResolution.x);
+ //#define AA
+
+ // sea
+ const int ITER_GEOMETRY = 3;
+ const int ITER_FRAGMENT = 5;
+ const float SEA_HEIGHT = 0.6;
+ const float SEA_CHOPPY = 4.0;
+ const float SEA_SPEED = 0.8;
+ const float SEA_FREQ = 0.16;
+ const vec3 SEA_BASE = vec3(0.0,0.09,0.18);
+ const vec3 SEA_WATER_COLOR = vec3(0.8,0.9,0.6)*0.6;
+// const float SEA_TIME = (1.0 + iTime * SEA_SPEED);
+ const mat2 octave_m = mat2(1.6,1.2,-1.2,1.6);
+
+ float SEA_TIME() {
+ return (1.0 + iTime * SEA_SPEED);
+ }
+
+ // math
+ mat3 fromEuler(vec3 ang) {
+ vec2 a1 = vec2(sin(ang.x),cos(ang.x));
+ vec2 a2 = vec2(sin(ang.y),cos(ang.y));
+ vec2 a3 = vec2(sin(ang.z),cos(ang.z));
+ mat3 m;
+ m[0] = vec3(a1.y*a3.y+a1.x*a2.x*a3.x,a1.y*a2.x*a3.x+a3.y*a1.x,-a2.y*a3.x);
+ m[1] = vec3(-a2.y*a1.x,a1.y*a2.y,a2.x);
+ m[2] = vec3(a3.y*a1.x*a2.x+a1.y*a3.x,a1.x*a3.x-a1.y*a3.y*a2.x,a2.y*a3.y);
+ return m;
+ }
+ float hash( vec2 p ) {
+ float h = dot(p,vec2(127.1,311.7));
+ return fract(sin(h)*43758.5453123);
+ }
+ float noise( in vec2 p ) {
+ vec2 i = floor( p );
+ vec2 f = fract( p );
+ vec2 u = f*f*(3.0-2.0*f);
+ return -1.0+2.0*mix( mix( hash( i + vec2(0.0,0.0) ),
+ hash( i + vec2(1.0,0.0) ), u.x),
+ mix( hash( i + vec2(0.0,1.0) ),
+ hash( i + vec2(1.0,1.0) ), u.x), u.y);
+ }
+
+ // lighting
+ float diffuse(vec3 n,vec3 l,float p) {
+ return pow(dot(n,l) * 0.4 + 0.6,p);
+ }
+ float specular(vec3 n,vec3 l,vec3 e,float s) {
+ float nrm = (s + 8.0) / (PI * 8.0);
+ return pow(max(dot(reflect(e,n),l),0.0),s) * nrm;
+ }
+
+ // sky
+ vec3 getSkyColor(vec3 e) {
+ e.y = (max(e.y,0.0)*0.8+0.2)*0.8;
+ return vec3(pow(1.0-e.y,2.0), 1.0-e.y, 0.6+(1.0-e.y)*0.4) * 1.1;
+ }
+
+ // sea
+ float sea_octave(vec2 uv, float choppy) {
+ uv += noise(uv);
+ vec2 wv = 1.0-abs(sin(uv));
+ vec2 swv = abs(cos(uv));
+ wv = mix(wv,swv,wv);
+ return pow(1.0-pow(wv.x * wv.y,0.65),choppy);
+ }
+
+ float map(vec3 p) {
+ float freq = SEA_FREQ;
+ float amp = SEA_HEIGHT;
+ float choppy = SEA_CHOPPY;
+ vec2 uv = p.xz; uv.x *= 0.75;
+
+ float d, h = 0.0;
+ for(int i = 0; i < ITER_GEOMETRY; i++) {
+ d = sea_octave((uv+SEA_TIME())*freq,choppy);
+ d += sea_octave((uv-SEA_TIME())*freq,choppy);
+ h += d * amp;
+ uv *= octave_m; freq *= 1.9; amp *= 0.22;
+ choppy = mix(choppy,1.0,0.2);
+ }
+ return p.y - h;
+ }
+
+ float map_detailed(vec3 p) {
+ float freq = SEA_FREQ;
+ float amp = SEA_HEIGHT;
+ float choppy = SEA_CHOPPY;
+ vec2 uv = p.xz; uv.x *= 0.75;
+
+ float d, h = 0.0;
+ for(int i = 0; i < ITER_FRAGMENT; i++) {
+ d = sea_octave((uv+SEA_TIME())*freq,choppy);
+ d += sea_octave((uv-SEA_TIME())*freq,choppy);
+ h += d * amp;
+ uv *= octave_m; freq *= 1.9; amp *= 0.22;
+ choppy = mix(choppy,1.0,0.2);
+ }
+ return p.y - h;
+ }
+
+ vec3 getSeaColor(vec3 p, vec3 n, vec3 l, vec3 eye, vec3 dist) {
+ float fresnel = clamp(1.0 - dot(n,-eye), 0.0, 1.0);
+ fresnel = min(pow(fresnel,3.0), 0.5);
+
+ vec3 reflected = getSkyColor(reflect(eye,n));
+ vec3 refracted = SEA_BASE + diffuse(n,l,80.0) * SEA_WATER_COLOR * 0.12;
+
+ vec3 color = mix(refracted,reflected,fresnel);
+
+ float atten = max(1.0 - dot(dist,dist) * 0.001, 0.0);
+ color += SEA_WATER_COLOR * (p.y - SEA_HEIGHT) * 0.18 * atten;
+
+ color += vec3(specular(n,l,eye,60.0));
+
+ return color;
+ }
+
+ // tracing
+ vec3 getNormal(vec3 p, float eps) {
+ vec3 n;
+ n.y = map_detailed(p);
+ n.x = map_detailed(vec3(p.x+eps,p.y,p.z)) - n.y;
+ n.z = map_detailed(vec3(p.x,p.y,p.z+eps)) - n.y;
+ n.y = eps;
+ return normalize(n);
+ }
+
+ float heightMapTracing(vec3 ori, vec3 dir, out vec3 p) {
+ float tm = 0.0;
+ float tx = 1000.0;
+ float hx = map(ori + dir * tx);
+ if(hx > 0.0) {
+ p = ori + dir * tx;
+ return tx;
+ }
+ float hm = map(ori + dir * tm);
+ float tmid = 0.0;
+ for(int i = 0; i < NUM_STEPS; i++) {
+ tmid = mix(tm,tx, hm/(hm-hx));
+ p = ori + dir * tmid;
+ float hmid = map(p);
+ if(hmid < 0.0) {
+ tx = tmid;
+ hx = hmid;
+ } else {
+ tm = tmid;
+ hm = hmid;
+ }
+ }
+ return tmid;
+ }
+
+ vec3 getPixel(in vec2 coord, float iTime) {
+ vec2 uv = coord / iResolution.xy;
+ uv = uv * 2.0 - 1.0;
+ uv.x *= iResolution.x / iResolution.y;
+
+ // ray
+ vec3 ang = vec3(sin(iTime*3.0)*4.1,sin(iTime)*0.2+0.3,iTime);
+ vec3 ori = vec3(0.0,3.5,iTime*5.0);
+ vec3 dir = normalize(vec3(uv.xy,-2.0)); dir.z += length(uv) * 0.14;
+ dir = normalize(dir) * fromEuler(ang);
+
+ // tracing
+ vec3 p;
+ heightMapTracing(ori,dir,p);
+ vec3 dist = p - ori;
+ vec3 n = getNormal(p, dot(dist,dist) * 0.1 / iResolution.x);
+ vec3 light = normalize(vec3(0.0,1.0,0.8));
+
+ // color
+ return mix(
+ getSkyColor(dir),
+ getSeaColor(p,n,light,dir,dist),
+ pow(smoothstep(0.0,-0.02,dir.y),0.2));
+ }
+
+ // main
+ vec4 main(in vec2 fragCoord ) {
+ float iTime = 15 * 0.3;// + iMouse.x*0.01;
+// float iTime = iTime * 0.3;// + iMouse.x*0.01;
+
+// #ifdef AA
+// vec3 color = vec3(0.0);
+// for(int i = -1; i <= 1; i++) {
+// for(int j = -1; j <= 1; j++) {
+// vec2 uv = fragCoord+vec2(i,j)/3.0;
+// color += getPixel(uv, iTime);
+// }
+// }
+// color /= 9.0;
+// #else
+ vec3 color = getPixel(fragCoord, iTime);
+// #endif
+
+ // post
+ return vec4(pow(color,vec3(0.65)), 1.0);
+ }
+ """.trimIndent()
+)
+
+@RequiresApi(33)
+@Language("AGSL")
+fun createShaderArtCodingIntroShader() = """
+uniform float2 iResolution;//px
+uniform float iTime;//s
+
+/* This animation is the material of my first youtube tutorial about creative
+ coding, which is a video in which I try to introduce programmers to GLSL
+ and to the wonderful world of shaders, while also trying to share my recent
+ passion for this community.
+ Video URL: https://youtu.be/f4s1h2YETNY
+*/
+
+//https://iquilezles.org/articles/palettes/
+vec3 palette( float t ) {
+ vec3 a = vec3(0.5, 0.5, 0.5);
+ vec3 b = vec3(0.5, 0.5, 0.5);
+ vec3 c = vec3(1.0, 1.0, 1.0);
+ vec3 d = vec3(0.263,0.416,0.557);
+
+ return a + b*cos( 6.28318*(c*t+d) );
+}
+
+
+vec4 main(vec2 fragCoords) {
+ vec2 uv = (fragCoords * 2.0 - iResolution.xy) / iResolution.y;
+ vec2 uv0 = uv;
+ vec3 finalColor = vec3(0.0);
+
+ for (float i = 0.0; i < 4.0; i++) {
+ uv = fract(uv * 1.5) - 0.5;
+
+ float d = length(uv) * exp(-length(uv0));
+
+ vec3 col = palette(length(uv0) + i*.4 + iTime*.4);
+
+ d = sin(d*8. + iTime)/8.;
+ d = abs(d);
+
+ d = pow(0.01 / d, 1.2);
+
+ finalColor += col * d;
+ }
+ return vec4(finalColor *.3, 1);
+}
+""".trimIndent().let { RuntimeShader(it) }
+
+@RequiresApi(33)
+@Language("AGSL")
+fun createMatrixRainShader() = """
+// https://www.shadertoy.com/view/XsXBWX
+uniform float2 iResolution;//px
+uniform float iTime;//s
+
+vec4 main(vec2 fragCoords) {
+ vec2 u = fragCoords / 10.;
+ vec4 iDate = vec4(2024., 1., 6., iTime);
+ vec4 o = -iDate;
+ o.g=.5-6.*fract((u.x*.2+u.y*.01)*fract(u.x*.61)-o.w);
+ o=vec4(u.x+9.,u.x,u.x-9.,fract(u.y*-.61));
+ o=o.w-o.w*abs(20.*fract((u.y*.2+(o.g-o*o.w*o.w)*.01)*(1.-o.w)+iDate.w)-1.);
+ return o;
+}
+""".trimIndent().let { RuntimeShader(it) }
+
+@RequiresApi(33)
+@Language("AGSL")
+fun createTestShader() = """
+uniform float2 iResolution;//px
+uniform float iTime;//s
+
+vec4 main(vec2 fragCoords) {
+ vec2 uv = fragCoords / iResolution;
+ return vec4((sin(iTime * 2.) + 1)* .31,uv.xy,1.0);
+}
+""".trimIndent().let { RuntimeShader(it) }
+
+@Composable
+private fun produceDrawLoopCounter(speed: Float = 1f): State {
+ return produceState(0f) {
+ while (true) {
+ withInfiniteAnimationFrameMillis {
+ value = it / 1000f * speed
+ }
+ }
+ }
+}
+
+@RequiresApi(33)
+@WearPreviewLargeRound
+@Composable
+private fun ShaderExperimentsPreview() = OClockRootCanvas {
+ ShaderExperiments()
+}
+
+@RequiresApi(33)
+@WearPreviewLargeRound
+@Composable
+private fun ShaderExperiments2Preview() = OClockRootCanvas {
+ ShaderExperiments(remember { createTestShader() })
+}
+
+@RequiresApi(33)
+@WearPreviewLargeRound
+@Composable
+private fun ShaderArtCodingIntroPreview() = OClockRootCanvas {
+ ShaderExperiments(remember { createShaderArtCodingIntroShader() })
+}
+
+@RequiresApi(33)
+@WearPreviewLargeRound
+@Composable
+private fun ShaderMatrixRainPreview() = OClockRootCanvas {
+ ShaderExperiments(remember { createMatrixRainShader() })
+}
diff --git a/shared/src/main/kotlin/cleanthisbeforerelease/experiments/TextOnPathExperiments.kt b/shared/src/main/kotlin/cleanthisbeforerelease/experiments/TextOnPathExperiments.kt
new file mode 100644
index 0000000..04f9730
--- /dev/null
+++ b/shared/src/main/kotlin/cleanthisbeforerelease/experiments/TextOnPathExperiments.kt
@@ -0,0 +1,179 @@
+package org.splitties.compose.oclock.sample.cleanthisbeforerelease.experiments
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.PaintingStyle
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.AndroidFont
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.googlefonts.Font
+import androidx.compose.ui.text.googlefonts.GoogleFont
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextGeometricTransform
+import androidx.compose.ui.text.style.TextMotion
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.util.fastForEachIndexed
+import org.splitties.compose.oclock.LocalIsAmbient
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.sample.WatchFacePreview
+import org.splitties.compose.oclock.sample.WearPreviewSizesProvider
+import org.splitties.compose.oclock.sample.extensions.centerAsTopLeft
+import org.splitties.compose.oclock.sample.extensions.drawTextOnPath
+import org.splitties.compose.oclock.sample.extensions.rememberStateWithSize
+import org.splitties.compose.oclock.sample.extensions.setFrom
+import org.splitties.compose.oclock.sample.extensions.text.rememberTextOnPathMeasurer
+import org.splitties.compose.oclock.sample.googleFontProvider
+
+@Composable
+fun TextOnPathExperiment(finalBrush: Brush) {
+ val font = remember {
+ Font(
+ googleFont = GoogleFont("Jost"),
+// googleFont = GoogleFont("Raleway"),
+// googleFont = GoogleFont("Reem Kufi"),
+// googleFont = GoogleFont("Montez"),
+ fontProvider = googleFontProvider,
+ ) as AndroidFont
+ }
+ val fontFamily = remember(font) { FontFamily(font) }
+ val context = LocalContext.current
+ val brush by produceState(finalBrush, font) {
+ val result = runCatching {
+ font.typefaceLoader.awaitLoad(context, font)
+ }
+ value = if (result.isSuccess) finalBrush else Brush.linearGradient(listOf(Color.Red))
+ }
+ val textStyle = remember(fontFamily, brush) {
+ TextStyle.Default.copy(
+ brush = null,
+ fontFamily = fontFamily,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.W600,
+ textAlign = TextAlign.Start,
+ lineHeight = 20.sp,
+ textMotion = TextMotion.Animated,
+ textGeometricTransform = TextGeometricTransform(
+ scaleX = 1f,
+ skewX = Float.MIN_VALUE
+ )
+ )
+ }
+ val textMeasurer = rememberTextOnPathMeasurer(cacheSize = 0)
+ val isAmbient by LocalIsAmbient.current
+ val cachedPath = remember { Path() }.let { path ->
+ rememberStateWithSize {
+ path.arcTo(
+ rect = Rect(Offset.Zero, size).deflate(textStyle.fontSize.toPx()),
+ startAngleDegrees = -180f,
+ sweepAngleDegrees = 359f,
+ forceMoveTo = true
+ )
+ path
+ }
+ }
+ val txt = "Hello Romain! Bonjour Romain!".repeat(2)
+ val txtList = remember {
+ txt.map { c ->
+ val str = c.toString()
+ textMeasurer.measure(
+ text = str,
+ style = textStyle.copy(Color.Red)
+ )
+ }
+ }
+ val text = remember {
+ textMeasurer.measure(
+ text = txt,
+ style = textStyle.copy(Color.White.copy(alpha = .5f))
+ )
+ }
+ val colors = remember { listOf(Color.Gray, Color.White) }
+ val paint = rememberStateWithSize {
+ Paint().also {
+ it.pathEffect = PathEffect.cornerPathEffect(50f)
+ Brush.verticalGradient(colors,
+ startY = center.y - size.height / 6f,
+ endY = center.y + size.height / 6f,
+ ).applyTo(size, it, 1f)
+ it.alpha = 1f
+ it.style = PaintingStyle.Fill
+ }
+ }
+ val outlinePaint = rememberStateWithSize {
+ Paint().also {
+ it.setFrom(paint.get())
+ Brush.verticalGradient(
+ colors.asReversed(),
+ startY = center.y - size.height / 3f,
+ endY = center.y + size.height / 3f,
+ ).applyTo(size, it, 1f)
+ it.strokeWidth = 2.dp.toPx()
+ it.style = PaintingStyle.Stroke
+ }
+ }
+ OClockCanvas {
+ val y = 0f
+ val path = cachedPath.get()
+// drawPath(path, Color.Green)
+ val s = size / 3f
+ if (false) drawRect(
+ brush = brush,
+ topLeft = center.centerAsTopLeft(s),
+ size = s,
+ style = Stroke(
+ width = 1.dp.toPx(),
+ pathEffect = PathEffect.cornerPathEffect(50f)
+ )
+ )
+ val r = Rect(center.centerAsTopLeft(s), s)
+ drawContext.canvas.drawRect(r, paint.get())
+ drawContext.canvas.drawRect(r, outlinePaint.get())
+ txtList.fastForEachIndexed { index, textOnPathLayoutResult ->
+ var offset: Float = 0f
+ var i = 0
+ while (i < index) {
+ val current = txtList[i]
+ offset += current.internalResult.getBoundingBox(current.layoutInput.text.lastIndex).right
+// offset += txtList[i].internalResult.size.width - 1
+ i++
+ }
+ drawTextOnPath(
+ textOnPathLayoutResult,
+ path = path,
+ offset = Offset(x = offset, y = y),
+// blendMode = BlendMode.Darken
+ )
+ }
+ drawTextOnPath(
+ text,
+ path = path,
+ alpha = .5f,
+ offset = Offset(x = 0f, y = y),
+// blendMode = BlendMode.SrcIn
+ )
+ }
+}
+
+@WatchFacePreview
+@Composable
+private fun TextOnPathExperimentPreview(
+ @PreviewParameter(WearPreviewSizesProvider::class) size: Dp
+) = WatchFacePreview(size) {
+ TextOnPathExperiment(finalBrush = SolidColor(Color.Magenta))
+}
diff --git a/shared/src/main/kotlin/elements/ClockHand.kt b/shared/src/main/kotlin/elements/ClockHand.kt
new file mode 100644
index 0000000..7d48e8b
--- /dev/null
+++ b/shared/src/main/kotlin/elements/ClockHand.kt
@@ -0,0 +1,59 @@
+package org.splitties.compose.oclock.sample.elements
+
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.DrawStyle
+import androidx.compose.ui.graphics.drawscope.Fill
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.unit.dp
+
+fun DrawScope.clockHand(
+ color: Color,
+ width: Float = 10.dp.toPx(),
+ height: Float = size.height / 4f,
+ style: DrawStyle = Fill,
+ blendMode: BlendMode
+) {
+ val halfWidth = width / 2f
+ val finalHeight = height + halfWidth
+ drawRoundRect(
+ color = color,
+ topLeft = Offset(
+ x = center.x - width / 2f,
+ y = center.y - finalHeight + width / 2f
+ ),
+ size = Size(width, finalHeight),
+ cornerRadius = CornerRadius(width),
+ style = style,
+ blendMode = blendMode
+ )
+}
+
+fun DrawScope.clockHand(
+ brush: Brush,
+ width: Float = 10.dp.toPx(),
+ height: Float = size.height / 4f,
+ style: DrawStyle = Fill,
+ blendMode: BlendMode = BlendMode.SrcOver
+) {
+ val padding = if (style is Stroke) style.width else 0f
+ val finalWidth = width - padding
+ val halfWidth = finalWidth / 2f
+ val finalHeight = height + halfWidth - padding / 2f
+ drawRoundRect(
+ brush = brush,
+ topLeft = Offset(
+ x = center.x - halfWidth,
+ y = center.y - finalHeight + halfWidth
+ ),
+ size = Size(finalWidth, finalHeight),
+ cornerRadius = CornerRadius(finalWidth),
+ style = style,
+ blendMode = blendMode
+ )
+}
diff --git a/shared/src/main/kotlin/elements/MyPath.kt b/shared/src/main/kotlin/elements/MyPath.kt
new file mode 100644
index 0000000..58ce9a5
--- /dev/null
+++ b/shared/src/main/kotlin/elements/MyPath.kt
@@ -0,0 +1,165 @@
+package org.splitties.compose.oclock.sample.elements
+
+import androidx.annotation.FloatRange
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.Stroke
+import org.splitties.compose.oclock.sample.extensions.cubicTo
+import org.splitties.compose.oclock.sample.extensions.lineTo
+import org.splitties.compose.oclock.sample.extensions.moveTo
+import org.splitties.compose.oclock.sample.extensions.offsetBy
+import org.splitties.compose.oclock.sample.extensions.plus
+import org.splitties.compose.oclock.sample.extensions.quadraticBezierTo
+import org.splitties.compose.oclock.sample.extensions.rotateAround
+import kotlin.math.sqrt
+
+fun Path.setToStar4(
+ side: Float,
+ topLeft: Offset = Offset.Zero,
+) {
+ setToStarX(count = 6, side = side, topLeft = topLeft)
+}
+
+fun Path.setToStarX(
+ count: Int,
+ side: Float,
+ topLeft: Offset = Offset.Zero,
+) {
+ if (isEmpty.not()) reset()
+ val half = side / 2f
+ val topMiddle = topLeft.offsetBy(x = half)
+ val center = topLeft + half
+ moveTo(topMiddle)
+ for (i in 1..count) {
+ val target = topMiddle.rotateAround(pivot = center, degrees = 360f * i / count)
+ quadraticBezierTo(center, target)
+ }
+ close()
+}
+
+fun Path.setToHeart(
+ topLeft: Offset = Offset.Zero,
+ size: Size,
+ @FloatRange(.0, 1.65) tipSharpnessRatio: Float = 1.1f
+) {
+ if (isEmpty.not()) reset()
+ val halfH = size.height / 2f
+ val halfW = size.width / 2f
+ val topControl = halfH * .68f
+ val tipControl = halfH * tipSharpnessRatio
+ val topMiddle = topLeft.offsetBy(
+ x = halfW,
+ y = halfW * .5f
+ )
+ moveTo(topMiddle)
+ cubicTo(
+ topMiddle.offsetBy(y = -topControl),
+ topMiddle.offsetBy(x = -halfW, y = -topControl),
+ topMiddle.offsetBy(x = -halfW) // Left
+ )
+ arcTo(Rect(topMiddle, topMiddle.offsetBy(x = -halfW)), startAngleDegrees = 0f, -180f, forceMoveTo = false)
+ cubicTo(
+ topMiddle.offsetBy(x = -halfW, y = topControl),
+ topMiddle.offsetBy(y = tipControl),
+ topMiddle.copy(y = topLeft.y + size.height) // Bottom
+ )
+ cubicTo(
+ topMiddle.offsetBy(y = tipControl),
+ topMiddle.offsetBy(x = halfW, y = topControl),
+ topMiddle.offsetBy(x = halfW) // Right
+ )
+ cubicTo(
+ topMiddle.offsetBy(x = halfW, y = -topControl),
+ topMiddle.offsetBy(y = -topControl),
+ topMiddle // Top
+ )
+ close()
+}
+
+fun Path.setToSketchyHeart(
+ size: Size,
+ topLeft: Offset = Offset.Zero,
+) {
+ if (isEmpty.not()) reset()
+ val halfH = size.height / 2f
+ val halfW = size.width / 2f
+ val circlesRadius = size.width / 4f
+ val topPoint = topLeft.offsetBy(x = halfW, y = circlesRadius)
+ val bottomMiddle = topLeft.offsetBy(x = halfW, y = size.height)
+ moveTo(bottomMiddle)
+ val bottomControl = topLeft.offsetBy(x = halfW, y = size.height * .8f)
+ val leftEdge = topLeft.offsetBy(y = size.height / 4)
+ val rightEdge = topLeft.offsetBy(x = size.width,y = size.height / 4)
+ cubicTo(
+ cp1 = bottomMiddle,
+ cp2 = bottomControl,
+ point = bottomMiddle
+ )
+ cubicTo(
+ cp1 = leftEdge.offsetBy(y = circlesRadius),
+ cp2 = leftEdge.offsetBy(y = - circlesRadius),
+ point = leftEdge
+ )
+ val topMiddle = topLeft.offsetBy(x = halfW)
+ val topLeftEdge = topMiddle.offsetBy(x = -circlesRadius)
+ val topRightEdge = topMiddle.offsetBy(x = circlesRadius)
+ cubicTo(
+ cp1 = topLeft,
+ cp2 = topLeft.offsetBy(x = halfW),
+ point = topLeftEdge
+ )
+ cubicTo(
+ cp1 = topMiddle,
+ cp2 = topPoint,
+ point = topPoint
+ )
+ cubicTo(
+ cp1 = topPoint,
+ cp2 = topMiddle,
+ point = topRightEdge
+ )
+ cubicTo(
+ cp1 = rightEdge.offsetBy(y = -circlesRadius),
+ cp2 = rightEdge.offsetBy(y = circlesRadius),
+ point = rightEdge
+ )
+ cubicTo(
+ cp1 = bottomControl,
+ cp2 = bottomMiddle,
+ point = bottomMiddle
+ )
+ close()
+}
+
+fun Path.setToKotlinLogo(
+ side: Float,
+ topLeft: Offset = Offset.Zero,
+) {
+ if (isEmpty.not()) reset()
+ moveTo(topLeft)
+ lineTo(topLeft.offsetBy(x = side))
+ lineTo(topLeft.offsetBy(x = side / 2f, y = side / 2f))
+ lineTo(topLeft.offsetBy(x = side, y = side))
+ lineTo(topLeft.offsetBy(y = side))
+ close()
+}
+
+fun Path.setToKotlinLogo(
+ side: Float,
+ topLeft: Offset = Offset.Zero,
+ stroke: Stroke? = null,
+) {
+ val strokeWidth = stroke?.width ?: 0f
+ val halfStroke = strokeWidth / 2f
+ val endOffset = if (stroke != null) sqrt(halfStroke * halfStroke * 2) else 0f
+ if (isEmpty.not()) reset()
+ val logoCenterX = side / 2f - endOffset
+ moveTo(topLeft + halfStroke)
+ lineTo(topLeft.offsetBy(x = side - endOffset - halfStroke, y = halfStroke))
+ lineTo(topLeft.offsetBy(x = logoCenterX, y = side / 2f))
+ lineTo(topLeft.offsetBy(x = side - endOffset - halfStroke, y = side - halfStroke))
+ lineTo(topLeft.offsetBy(x = halfStroke, y = side - halfStroke))
+ close()
+}
diff --git a/shared/src/main/kotlin/elements/SinusoidalMinutes.kt b/shared/src/main/kotlin/elements/SinusoidalMinutes.kt
new file mode 100644
index 0000000..23c17f0
--- /dev/null
+++ b/shared/src/main/kotlin/elements/SinusoidalMinutes.kt
@@ -0,0 +1,171 @@
+package org.splitties.compose.oclock.sample.elements
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.geometry.center
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathMeasure
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.scale
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import org.splitties.compose.oclock.LocalIsAmbient
+import org.splitties.compose.oclock.LocalTime
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.sample.WatchFacePreview
+import org.splitties.compose.oclock.sample.WearPreviewSizesProvider
+import org.splitties.compose.oclock.sample.elements.vectors.rememberComposeMultiplatformVectorPainter
+import org.splitties.compose.oclock.sample.elements.vectors.rememberWearOsLogoVectorPainter
+import org.splitties.compose.oclock.sample.extensions.centerAsTopLeft
+import org.splitties.compose.oclock.sample.extensions.drawPainter
+import org.splitties.compose.oclock.sample.extensions.moveTo
+import org.splitties.compose.oclock.sample.extensions.quadraticBezierTo
+import org.splitties.compose.oclock.sample.extensions.rememberStateWithSize
+import org.splitties.compose.oclock.sample.extensions.rotateAround
+
+@WatchFacePreview
+@Composable
+fun SinusoidalMinutesPreview(
+ @PreviewParameter(WearPreviewSizesProvider::class) size: Dp
+) = WatchFacePreview(size) {
+ SinusoidalMinutes()
+ val wearOsLogo = rememberWearOsLogoVectorPainter()
+ val composeLogo = rememberComposeMultiplatformVectorPainter()
+ val washingOutFilter = remember {
+ ColorFilter.lighting(
+ add = Color.Gray,
+ multiply = Color.hsl(hue = 0f, saturation = 0f, lightness = .2f)
+ )
+ }
+ OClockCanvas {
+ scale(.2f) {
+ drawPainter(
+ wearOsLogo,
+ topLeft = center.centerAsTopLeft(wearOsLogo.intrinsicSize),
+ colorFilter = washingOutFilter,
+ alpha = .5f
+ )
+ }
+ drawPainter(
+ painter = composeLogo,
+ topLeft = center.centerAsTopLeft(composeLogo.intrinsicSize)
+ )
+ }
+}
+
+@Composable
+fun SinusoidalMinutes(
+ interactiveFillBrush: Brush = remember { SolidColor(Color.Cyan) },
+ ambientFillBrush: Brush = remember { SolidColor(Color.Cyan) }
+) {
+ val cachedPath = remember { Path() }.let { path ->
+ rememberStateWithSize {
+ path.apply { setToSineWaveLike(size) }
+ }
+ }
+ val cachedPathMeasure = rememberStateWithSize {
+ PathMeasure().also {
+ it.setPath(
+ path = cachedPath.get(),
+ forceClosed = false
+ )
+ }
+ }
+ val segment = remember { Path() }
+ val time = LocalTime.current
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ val path by cachedPath
+ val minuteRatio = time.minutesWithSeconds / 60f
+ val pathMeasure by cachedPathMeasure
+ val offset = pathMeasure.let {
+ it.getPosition(distance = minuteRatio * it.length)
+
+ }
+ pathMeasure.getSegment(
+ startDistance = 0f,
+ stopDistance = minuteRatio * pathMeasure.length,
+ destination = segment
+ )
+ drawPath(path, Color.LightGray, style = Stroke(width = 1.dp.toPx()))
+ val fillBrush = if (isAmbient) ambientFillBrush else interactiveFillBrush
+ drawPath(segment, fillBrush, style = Stroke(width = 3.dp.toPx()))
+ drawCircle(fillBrush, radius = 4.dp.toPx(), center = offset, blendMode = BlendMode.Plus)
+ }
+}
+
+private fun Path.setToSineWaveLike(size: Size) {
+ val center = size.center
+ val amplitude = size.height / 10f
+ val start = Offset(center.x, amplitude / 2f)
+ moveTo(start)
+ val count = 30
+ for (i in 1..count) {
+ val ratio = i.toFloat() / count
+ val anglePeriod = 360f / count
+ quadraticBezierTo(
+ p1 = start.copy(y = 0f).rotateAround(center, anglePeriod * (i - .75f)),
+ p2 = start.rotateAround(pivot = center, degrees = anglePeriod * (i - .5f))
+ )
+ quadraticBezierTo(
+ p1 = start.copy(y = amplitude).rotateAround(center, anglePeriod * (i - .25f)),
+ p2 = start.rotateAround(pivot = center, degrees = anglePeriod * (i))
+ )
+ }
+ close()
+}
+
+private fun Path.setToDecoration(size: Size) {
+ val start = Offset(size.width / 2f, 0f)
+ moveTo(start)
+ val amplitude = size.height / 10f
+ val count = 30
+ for (i in 0..count) {
+ val ratio = i.toFloat() / count
+ val topForward = start.rotateAround(pivot = size.center, degrees = 360f * ratio)
+ val bottomForward = start.copy(y = amplitude).rotateAround(size.center, 360f * ratio)
+ quadraticBezierTo(
+ p1 = topForward,
+ p2 = bottomForward
+ )
+ val p3 = bottomForward.rotateAround(pivot = size.center, degrees = 360f / count / 2f)
+ val p4 = topForward.rotateAround(pivot = size.center, degrees = 360f / count / 2f)
+ quadraticBezierTo(
+ p3, p4
+ )
+ }
+ close()
+}
+
+private fun Path.setToAccidentalArt1(size: Size) {
+ val center = size.center
+ val amplitude = size.height / 10f
+ val start = Offset(center.x, amplitude / 2f)
+ moveTo(start)
+ val count = 60
+ for (i in 0..count) {
+ val ratio = i.toFloat() / count
+ val anglePeriod = 360f / count
+ val topForward = start.rotateAround(pivot = center, degrees = 360f * ratio)
+ val bottomForward = start.copy(y = amplitude).rotateAround(size.center, 360f * ratio)
+ quadraticBezierTo(
+ p1 = topForward,
+ p2 = start.rotateAround(center, anglePeriod)
+ )
+ val p3 = bottomForward.rotateAround(pivot = size.center, degrees = 360f / count / 2f)
+ val p4 = topForward.rotateAround(pivot = size.center, degrees = 360f / count / 2f)
+ quadraticBezierTo(
+ p3, p4
+ )
+ }
+ close()
+}
diff --git a/shared/src/main/kotlin/elements/vectors/composeMultiplatformLogo.kt b/shared/src/main/kotlin/elements/vectors/composeMultiplatformLogo.kt
new file mode 100644
index 0000000..91f84d6
--- /dev/null
+++ b/shared/src/main/kotlin/elements/vectors/composeMultiplatformLogo.kt
@@ -0,0 +1,239 @@
+package org.splitties.compose.oclock.sample.elements.vectors
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.PathFillType
+import androidx.compose.ui.graphics.vector.VectorPainter
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun rememberComposeMultiplatformVectorPainter(): VectorPainter {
+ return rememberVectorPainter(remember { composeMultiplatformVector() })
+}
+
+fun composeMultiplatformVector(): ImageVector = ImageVector.Builder(
+ name = "Compose Multiplatform",
+ defaultWidth = 67.dp,
+ defaultHeight = 74.dp,
+ viewportWidth = 67f,
+ viewportHeight = 74f
+).apply {
+ path(
+ fill = SolidColor(Color(0xFFFFFFFF)),
+ fillAlpha = 1.0f,
+ stroke = null,
+ strokeAlpha = 1.0f,
+ strokeLineWidth = 1.0f,
+ strokeLineCap = StrokeCap.Butt,
+ strokeLineJoin = StrokeJoin.Miter,
+ strokeLineMiter = 1.0f,
+ pathFillType = PathFillType.EvenOdd
+ ) {
+ moveTo(35.999f, 2.663f)
+ arcToRelative(
+ 5.01f,
+ 5.01f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = false,
+ -4.998f,
+ 0f
+ )
+ lineToRelative(-26.5f, 15.253f)
+ arcToRelative(
+ 4.994f,
+ 4.994f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = false,
+ -1.198f,
+ 0.962f
+ )
+ lineToRelative(11.108f, 6.366f)
+ curveToRelative(0.268f, -0.29f, 0.58f, -0.54f, 0.931f, -0.744f)
+ lineToRelative(16.156f, -9.342f)
+ arcToRelative(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 4.004f, 0f)
+ lineTo(51.657f, 24.5f)
+ curveToRelative(0.351f, 0.203f, 0.664f, 0.455f, 0.932f, 0.744f)
+ lineToRelative(11.108f, -6.366f)
+ arcToRelative(
+ 4.991f,
+ 4.991f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = false,
+ -1.198f,
+ -0.962f
+ )
+ lineToRelative(-26.5f, -15.253f)
+ close()
+ moveToRelative(28.723f, 17.933f)
+ lineToRelative(-11.183f, 6.408f)
+ curveToRelative(0.076f, 0.31f, 0.116f, 0.632f, 0.116f, 0.959f)
+ verticalLineToRelative(17.794f)
+ arcToRelative(
+ 4f,
+ 4f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = true,
+ -1.958f,
+ 3.44f
+ )
+ lineToRelative(-16.235f, 9.638f)
+ arcToRelative(
+ 3.998f,
+ 3.998f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = true,
+ -0.962f,
+ 0.412f
+ )
+ verticalLineToRelative(12.63f)
+ arcToRelative(
+ 5.005f,
+ 5.005f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = false,
+ 1.428f,
+ -0.569f
+ )
+ lineToRelative(26.62f, -15.73f)
+ arcTo(
+ 4.986f,
+ 4.986f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = false,
+ 65f,
+ 51.284f
+ )
+ verticalLineTo(22.237f)
+ curveToRelative(0f, -0.567f, -0.097f, -1.12f, -0.278f, -1.64f)
+ close()
+ moveTo(2f, 22.237f)
+ curveToRelative(0f, -0.567f, 0.097f, -1.12f, 0.278f, -1.64f)
+ lineToRelative(11.183f, 6.407f)
+ curveToRelative(-0.076f, 0.31f, -0.116f, 0.632f, -0.116f, 0.959f)
+ verticalLineToRelative(18.633f)
+ arcToRelative(
+ 4f,
+ 4f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = false,
+ 2.08f,
+ 3.509f
+ )
+ lineToRelative(16.074f, 8.8f)
+ curveToRelative(0.32f, 0.174f, 0.656f, 0.302f, 1.001f, 0.384f)
+ verticalLineToRelative(12.638f)
+ arcToRelative(
+ 5.005f,
+ 5.005f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = true,
+ -1.517f,
+ -0.533f
+ )
+ lineTo(4.603f, 57.02f)
+ arcTo(4.987f, 4.987f, 0f, isMoreThanHalf = false, isPositiveArc = true, 2f, 52.642f)
+ verticalLineTo(22.237f)
+ close()
+ moveTo(30.002f, 0.935f)
+ arcToRelative(
+ 7.014f,
+ 7.014f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = true,
+ 6.996f,
+ 0f
+ )
+ lineToRelative(26.5f, 15.253f)
+ arcTo(6.98f, 6.98f, 0f, isMoreThanHalf = false, isPositiveArc = true, 67f, 22.238f)
+ verticalLineToRelative(29.047f)
+ arcToRelative(
+ 6.98f,
+ 6.98f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = true,
+ -3.433f,
+ 6.009f
+ )
+ lineToRelative(-26.62f, 15.731f)
+ arcToRelative(
+ 7.014f,
+ 7.014f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = true,
+ -6.923f,
+ 0.12f
+ )
+ lineTo(3.644f, 58.771f)
+ arcTo(6.981f, 6.981f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0f, 52.641f)
+ verticalLineTo(22.238f)
+ arcToRelative(
+ 6.98f,
+ 6.98f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = true,
+ 3.502f,
+ -6.05f
+ )
+ lineTo(30.002f, 0.936f)
+ close()
+ moveToRelative(-8.604f, 27.552f)
+ lineToRelative(10.582f, -6.11f)
+ curveToRelative(0.94f, -0.542f, 2.1f, -0.542f, 3.04f, 0f)
+ lineToRelative(10.582f, 6.11f)
+ arcToRelative(
+ 2.996f,
+ 2.996f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = true,
+ 1.503f,
+ 2.593f
+ )
+ verticalLineToRelative(11.653f)
+ curveToRelative(0f, 1.056f, -0.56f, 2.034f, -1.473f, 2.576f)
+ lineToRelative(-10.643f, 6.308f)
+ arcToRelative(
+ 3.044f,
+ 3.044f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = true,
+ -3.009f,
+ 0.052f
+ )
+ lineToRelative(-10.52f, -5.75f)
+ arcToRelative(
+ 2.996f,
+ 2.996f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = true,
+ -1.565f,
+ -2.627f
+ )
+ verticalLineTo(31.08f)
+ curveToRelative(0f, -1.068f, 0.573f, -2.056f, 1.503f, -2.593f)
+ close()
+ }
+}.build()
+
diff --git a/shared/src/main/kotlin/elements/vectors/wearOsLogo.kt b/shared/src/main/kotlin/elements/vectors/wearOsLogo.kt
new file mode 100644
index 0000000..21baf0b
--- /dev/null
+++ b/shared/src/main/kotlin/elements/vectors/wearOsLogo.kt
@@ -0,0 +1,121 @@
+package org.splitties.compose.oclock.sample.elements.vectors
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.PathFillType
+import androidx.compose.ui.graphics.vector.VectorPainter
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun rememberWearOsLogoVectorPainter(): VectorPainter {
+ return rememberVectorPainter(remember { wearOsIcon() })
+}
+
+fun wearOsIcon(): ImageVector = ImageVector.Builder(
+ name = "WearOsIcon",
+ defaultWidth = 469.7.dp,
+ defaultHeight = 359.dp,
+ viewportWidth = 469.7f,
+ viewportHeight = 359f
+).apply {
+ path(
+ fill = SolidColor(Color(0xFF00A94B)),
+ fillAlpha = 1.0f,
+ stroke = null,
+ strokeAlpha = 1.0f,
+ strokeLineWidth = 1.0f,
+ strokeLineCap = StrokeCap.Butt,
+ strokeLineJoin = StrokeJoin.Miter,
+ strokeLineMiter = 1.0f,
+ pathFillType = PathFillType.NonZero
+ ) {
+ moveTo(418.59999999999997f, 146.4f)
+ arcTo(
+ 48.7f,
+ 48.7f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = true,
+ 369.9f,
+ 195.10000000000002f
+ )
+ arcTo(48.7f, 48.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, 321.2f, 146.4f)
+ arcTo(
+ 48.7f,
+ 48.7f,
+ 0f,
+ isMoreThanHalf = false,
+ isPositiveArc = true,
+ 418.59999999999997f,
+ 146.4f
+ )
+ close()
+ }
+ path(
+ fill = SolidColor(Color(0xFFFF4131)),
+ fillAlpha = 1.0f,
+ stroke = null,
+ strokeAlpha = 1.0f,
+ strokeLineWidth = 1.0f,
+ strokeLineCap = StrokeCap.Butt,
+ strokeLineJoin = StrokeJoin.Miter,
+ strokeLineMiter = 1.0f,
+ pathFillType = PathFillType.NonZero
+ ) {
+ moveTo(469.7f, 46.3f)
+ arcTo(45.5f, 45.5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 424.2f, 91.8f)
+ arcTo(45.5f, 45.5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 378.7f, 46.3f)
+ arcTo(45.5f, 45.5f, 0f, isMoreThanHalf = false, isPositiveArc = true, 469.7f, 46.3f)
+ close()
+ }
+ path(
+ fill = SolidColor(Color(0xFFFFBC00)),
+ fillAlpha = 1.0f,
+ stroke = null,
+ strokeAlpha = 1.0f,
+ strokeLineWidth = 1.0f,
+ strokeLineCap = StrokeCap.Butt,
+ strokeLineJoin = StrokeJoin.Miter,
+ strokeLineMiter = 1.0f,
+ pathFillType = PathFillType.NonZero
+ ) {
+ moveTo(305.5f, 359f)
+ curveToRelative(-17.4f, 0f, -34.1f, -10.1f, -41.6f, -27f)
+ lineTo(144.6f, 64.1f)
+ curveToRelative(-10.2f, -23f, 0.1f, -49.9f, 23.1f, -60.1f)
+ curveToRelative(23f, -10.2f, 49.9f, 0.1f, 60.1f, 23.1f)
+ lineToRelative(119.3f, 267.9f)
+ curveToRelative(10.2f, 23f, -0.1f, 49.9f, -23.1f, 60.1f)
+ curveTo(318f, 357.8f, 311.7f, 359f, 305.5f, 359f)
+ close()
+ }
+ path(
+ fill = SolidColor(Color(0xFF0085F7)),
+ fillAlpha = 1.0f,
+ stroke = null,
+ strokeAlpha = 1.0f,
+ strokeLineWidth = 1.0f,
+ strokeLineCap = StrokeCap.Butt,
+ strokeLineJoin = StrokeJoin.Miter,
+ strokeLineMiter = 1.0f,
+ pathFillType = PathFillType.NonZero
+ ) {
+ moveTo(164.7f, 358.3f)
+ curveToRelative(-19f, 0f, -37.1f, -11f, -45.3f, -29.4f)
+ lineTo(4.3f, 70.3f)
+ curveTo(-6.8f, 45.3f, 4.4f, 16f, 29.4f, 4.9f)
+ reflectiveCurveTo(83.7f, 5f, 94.8f, 30f)
+ lineToRelative(115.1f, 258.6f)
+ curveToRelative(11.1f, 25f, -0.1f, 54.3f, -25.1f, 65.4f)
+ curveTo(178.3f, 356.9f, 171.4f, 358.3f, 164.7f, 358.3f)
+ close()
+ }
+}.build()
+
diff --git a/shared/src/main/kotlin/extensions/AndroidTextPaint.kt b/shared/src/main/kotlin/extensions/AndroidTextPaint.kt
new file mode 100644
index 0000000..6554fc1
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/AndroidTextPaint.kt
@@ -0,0 +1,131 @@
+package extensions
+
+import android.text.TextPaint
+import androidx.annotation.VisibleForTesting
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.geometry.isSpecified
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.PaintingStyle
+import androidx.compose.ui.graphics.ShaderBrush
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.asComposePaint
+import androidx.compose.ui.graphics.drawscope.DrawStyle
+import androidx.compose.ui.graphics.drawscope.Fill
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.isSpecified
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.text.style.TextDecoration
+import kotlin.math.roundToInt
+
+internal class AndroidTextPaint(flags: Int, density: Float) : TextPaint(flags) {
+ init {
+ this.density = density
+ }
+
+ // A wrapper to use Compose Paint APIs on this TextPaint
+ private val composePaint: Paint = this.asComposePaint()
+
+ private var textDecoration: TextDecoration = TextDecoration.None
+
+ @VisibleForTesting
+ internal var shadow: Shadow = Shadow.None
+
+ private var drawStyle: DrawStyle? = null
+
+ fun setTextDecoration(textDecoration: TextDecoration?) {
+ if (textDecoration == null) return
+ if (this.textDecoration != textDecoration) {
+ this.textDecoration = textDecoration
+ isUnderlineText = TextDecoration.Underline in this.textDecoration
+ isStrikeThruText = TextDecoration.LineThrough in this.textDecoration
+ }
+ }
+
+ fun setShadow(shadow: Shadow?) {
+ if (shadow == null) return
+ if (this.shadow != shadow) {
+ this.shadow = shadow
+ if (this.shadow == Shadow.None) {
+ clearShadowLayer()
+ } else {
+ setShadowLayer(
+ correctBlurRadius(this.shadow.blurRadius),
+ this.shadow.offset.x,
+ this.shadow.offset.y,
+ this.shadow.color.toArgb()
+ )
+ }
+ }
+ }
+
+ fun setColor(color: Color) {
+ if (color.isSpecified) {
+ composePaint.color = color
+ composePaint.shader = null
+ }
+ }
+
+ fun setBrush(brush: Brush?, size: Size, alpha: Float = Float.NaN) {
+ // if size is unspecified and brush is not null, nothing should be done.
+ // it basically means brush is given but size is not yet calculated at this time.
+ if ((brush is SolidColor && brush.value.isSpecified) ||
+ (brush is ShaderBrush && size.isSpecified)) {
+ // alpha is always applied even if Float.NaN is passed to applyTo function.
+ // if it's actually Float.NaN, we simply send the current value
+ brush.applyTo(
+ size,
+ composePaint,
+ if (alpha.isNaN()) composePaint.alpha else alpha.coerceIn(0f, 1f)
+ )
+ } else if (brush == null) {
+ composePaint.shader = null
+ }
+ }
+
+ fun setDrawStyle(drawStyle: DrawStyle) {
+ if (this.drawStyle != drawStyle) {
+ this.drawStyle = drawStyle
+ when (drawStyle) {
+ Fill -> {
+ // Stroke properties such as strokeWidth, strokeMiter are not re-set because
+ // Fill style should make those properties no-op. Next time the style is set
+ // as Stroke, stroke properties get re-set as well.
+ composePaint.style = PaintingStyle.Fill
+ }
+ is Stroke -> {
+ composePaint.style = PaintingStyle.Stroke
+ composePaint.strokeWidth = drawStyle.width
+ composePaint.strokeMiterLimit = drawStyle.miter
+ composePaint.strokeJoin = drawStyle.join
+ composePaint.strokeCap = drawStyle.cap
+ composePaint.pathEffect = drawStyle.pathEffect
+ }
+ }
+ }
+ }
+
+ // BlendMode is only available to DrawScope.drawText.
+ // not intended to be used by TextStyle/SpanStyle.
+ var blendMode: BlendMode by composePaint::blendMode
+}
+
+/**
+ * Accepts an alpha value in the range [0f, 1f] then maps to an integer value
+ * in [0, 255] range.
+ */
+internal fun TextPaint.setAlpha(alpha: Float) {
+ if (!alpha.isNaN()) {
+ val alphaInt = alpha.coerceIn(0f, 1f).times(255).roundToInt()
+ setAlpha(alphaInt)
+ }
+}
+
+internal fun correctBlurRadius(blurRadius: Float) = if (blurRadius == 0f) {
+ Float.MIN_VALUE
+} else {
+ blurRadius
+}
diff --git a/shared/src/main/kotlin/extensions/Colors.kt b/shared/src/main/kotlin/extensions/Colors.kt
new file mode 100644
index 0000000..0ff4396
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/Colors.kt
@@ -0,0 +1,5 @@
+package org.splitties.compose.oclock.sample.extensions
+
+import androidx.compose.ui.graphics.Color
+
+fun List.loop(): List = this + this.first()
diff --git a/shared/src/main/kotlin/extensions/DrawScope.kt b/shared/src/main/kotlin/extensions/DrawScope.kt
new file mode 100644
index 0000000..0e1e14e
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/DrawScope.kt
@@ -0,0 +1,214 @@
+package org.splitties.compose.oclock.sample.extensions
+
+import android.graphics.Paint
+import android.graphics.drawable.Drawable
+import androidx.annotation.FloatRange
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.DefaultAlpha
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.asAndroidPath
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.DrawStyle
+import androidx.compose.ui.graphics.drawscope.DrawTransform
+import androidx.compose.ui.graphics.drawscope.Fill
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.TextUnitType
+import androidx.core.graphics.drawable.updateBounds
+import extensions.AndroidTextPaint
+import extensions.setAlpha
+import org.splitties.compose.oclock.sample.extensions.text.TextOnPathLayoutResult
+
+fun DrawScope.drawOval(
+ color: Color,
+ size: Size = this.size,
+ center: Offset = this.center,
+ @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f,
+ style: DrawStyle = Fill,
+ colorFilter: ColorFilter? = null,
+ blendMode: BlendMode = DrawScope.DefaultBlendMode
+) = drawOval(
+ color = color,
+ topLeft = center.centerAsTopLeft(size),
+ alpha = alpha,
+ size = size,
+ style = style,
+ colorFilter = colorFilter,
+ blendMode = blendMode
+)
+
+fun DrawScope.drawArc(
+ color: Color,
+ startAngle: Float,
+ sweepAngle: Float,
+ useCenter: Boolean,
+ size: Size = this.size,
+ center: Offset = this.center,
+ @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f,
+ style: DrawStyle = Fill,
+ colorFilter: ColorFilter? = null,
+ blendMode: BlendMode = DrawScope.DefaultBlendMode
+) = drawArc(
+ color = color,
+ topLeft = center.centerAsTopLeft(size),
+ startAngle = startAngle,
+ sweepAngle = sweepAngle,
+ useCenter = useCenter,
+ alpha = alpha,
+ size = size,
+ style = style,
+ colorFilter = colorFilter,
+ blendMode = blendMode
+)
+
+fun DrawScope.drawPainter(
+ painter: Painter,
+ topLeft: Offset = Offset.Zero,
+ size: Size = painter.intrinsicSize,
+ alpha: Float = DefaultAlpha,
+ colorFilter: ColorFilter? = null
+) {
+ withTransform({
+ translate(topLeft)
+ }) {
+ with(painter) {
+ draw(size, alpha, colorFilter)
+ }
+ }
+}
+
+fun DrawScope.drawTextOnPath(
+ textLayoutResult: TextOnPathLayoutResult,
+ path: Path,
+ offset: Offset = Offset.Zero,
+ @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f,
+ blendMode: BlendMode = DrawScope.DefaultBlendMode
+) {
+ drawContext.canvas.nativeCanvas.drawTextOnPath(
+ textLayoutResult.layoutInput.text.text,
+ path.asAndroidPath(),
+ offset.x,
+ offset.y,
+ textPaint.also {
+ it.setFrom(
+ textLayoutResult = textLayoutResult,
+ blendMode = blendMode,
+ alpha = alpha
+ )
+ }
+ )
+}
+
+private val textPaint = AndroidTextPaint(
+ flags = Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG,
+ density = 1f
+)
+
+context(DrawScope)
+internal fun AndroidTextPaint.setFrom(
+ textLayoutResult: TextOnPathLayoutResult,
+ blendMode: BlendMode,
+ @FloatRange(from = 0.0, to = 1.0) alpha: Float,
+) {
+ val layoutInput = textLayoutResult.layoutInput
+ val style = layoutInput.style
+ this.density = layoutInput.density.density
+ setColor(style.color)
+ setBrush(style.brush, size)
+ setShadow(style.shadow)
+ setDrawStyle(style.drawStyle ?: Fill)
+ this.blendMode = blendMode
+ when (style.fontSize.type) {
+ TextUnitType.Sp -> textSize = style.fontSize.toPx()
+ TextUnitType.Em -> {
+ textSize *= style.fontSize.value
+ }
+ else -> {} // Do nothing
+ }
+ textAlign = style.textAlign.toPaintAlign(layoutDirection)
+ setTextDecoration(style.textDecoration)
+
+ setAlpha(alpha)
+
+ // See internal fun AndroidTextPaint.applySpanStyle in TextPaintExtensions.android.kt
+ typeface = textLayoutResult.typeface
+ fontFeatureSettings = style.fontFeatureSettings
+ val textGeometricTransform = style.textGeometricTransform
+ textScaleX = textGeometricTransform?.scaleX ?: 1f
+ textSkewX = textGeometricTransform?.skewX ?: 0f
+ if (style.letterSpacing.type == TextUnitType.Sp && style.letterSpacing.value != 0.0f) {
+ val emWidth = textSize * textScaleX
+ val letterSpacingPx = style.letterSpacing.toPx()
+ // Do nothing if emWidth is 0.0f.
+ if (emWidth != 0.0f) {
+ letterSpacing = letterSpacingPx / emWidth
+ }
+ } else if (style.letterSpacing.type == TextUnitType.Em) {
+ letterSpacing = style.letterSpacing.value
+ }
+}
+
+private fun TextAlign.toPaintAlign(layoutDirection: LayoutDirection): Paint.Align = when (this) {
+ TextAlign.Left -> Paint.Align.LEFT
+ TextAlign.Right -> Paint.Align.RIGHT
+ TextAlign.Center -> Paint.Align.CENTER
+ TextAlign.Start -> when (layoutDirection) {
+ LayoutDirection.Ltr -> Paint.Align.LEFT
+ LayoutDirection.Rtl -> Paint.Align.RIGHT
+ }
+ TextAlign.End -> when (layoutDirection) {
+ LayoutDirection.Ltr -> Paint.Align.RIGHT
+ LayoutDirection.Rtl -> Paint.Align.LEFT
+ }
+ else -> TextAlign.Start.toPaintAlign(layoutDirection)
+}
+
+//TODO: Remove when https://issuetracker.google.com/issues/318384666 is fixed.
+inline fun DrawScope.rotate(
+ degrees: Float,
+ pivot: Offset = center,
+ block: DrawScope.() -> Unit
+) = withTransform({ rotate(degrees, pivot) }, block)
+
+//TODO: Remove when https://issuetracker.google.com/issues/318384666 is fixed.
+inline fun DrawScope.withTransform(
+ transformBlock: DrawTransform.() -> Unit,
+ drawBlock: DrawScope.() -> Unit
+) = with(drawContext) {
+ // Transformation can include inset calls which change the drawing area
+ // so cache the previous size before the transformation is done
+ // and reset it afterwards
+ val previousSize = size
+ canvas.save()
+ try {
+ transformBlock(transform)
+ drawBlock()
+ } finally {
+ canvas.restore()
+ size = previousSize
+ }
+}
+
+fun DrawScope.drawDrawable(
+ drawable: Drawable,
+ topLeft: Offset = Offset.Zero,
+ size: Size = this.size
+) {
+ val left = topLeft.x
+ val top = topLeft.y
+ val right = left + size.width
+ val bottom = top + size.height
+ drawable.updateBounds(
+ left = left.toInt(),
+ top = top.toInt(),
+ right = right.toInt(),
+ bottom = bottom.toInt(),
+ )
+ drawable.draw(drawContext.canvas.nativeCanvas)
+}
diff --git a/shared/src/main/kotlin/extensions/DrawTransform.kt b/shared/src/main/kotlin/extensions/DrawTransform.kt
new file mode 100644
index 0000000..a552f35
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/DrawTransform.kt
@@ -0,0 +1,10 @@
+@file:Suppress("NOTHING_TO_INLINE")
+
+package org.splitties.compose.oclock.sample.extensions
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.drawscope.DrawTransform
+
+inline fun DrawTransform.translate(offset: Offset) {
+ translate(left = offset.x, top = offset.y)
+}
diff --git a/shared/src/main/kotlin/extensions/Math.kt b/shared/src/main/kotlin/extensions/Math.kt
new file mode 100644
index 0000000..7a870c7
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/Math.kt
@@ -0,0 +1,6 @@
+package org.splitties.compose.oclock.sample.extensions
+
+import kotlin.math.PI
+
+fun Float.degreesToRadians(): Float = (PI / 180 * this).toFloat()
+fun Float.radiansToDegrees(): Float = this * (180 / PI).toFloat()
diff --git a/shared/src/main/kotlin/extensions/Offset.kt b/shared/src/main/kotlin/extensions/Offset.kt
new file mode 100644
index 0000000..39560c9
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/Offset.kt
@@ -0,0 +1,73 @@
+package org.splitties.compose.oclock.sample.extensions
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.unit.IntSize
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+
+context (DrawScope)
+fun Offset.rotate(degrees: Float): Offset {
+ return rotateAround(pivot = center, degrees = degrees)
+}
+
+fun Offset.rotateAround(
+ pivot: Offset,
+ degrees: Float,
+): Offset {
+ val angle = Math.toRadians(degrees.toDouble()).toFloat()
+ val cx = pivot.x
+ val cy = pivot.y
+ val offsetX = x - cx // Offset backwards to match the center of the Unit Circle.
+ val offsetY = y - cy // Offset backwards to match the center of the Unit Circle.
+
+ val offsetRotatedX = offsetX * cos(angle) - offsetY * sin(angle)
+ val offsetRotatedY = offsetX * sin(angle) + offsetY * cos(angle)
+ return Offset(
+ x = offsetRotatedX + cx, // Offset back, forward.
+ y = offsetRotatedY + cy // Offset back, forward.
+ )
+}
+
+fun Offset.centerAsTopLeft(size: Size): Offset {
+ return copy(x = x - size.width / 2f, y = y - size.height / 2f)
+}
+
+fun Offset.centerAsTopLeft(side: Float): Offset {
+ return copy(x = x - side / 2f, y = y - side / 2f)
+}
+
+fun Offset.centerAsTopLeft(size: IntSize): Offset {
+ return copy(x = x - size.width / 2f, y = y - size.height / 2f)
+}
+
+fun Offset.topCenterAsTopLeft(size: Size): Offset {
+ return copy(x = x - size.width / 2f)
+}
+
+fun Offset.topCenterAsTopLeft(size: IntSize): Offset {
+ return copy(x = x - size.width / 2f)
+}
+
+fun Offset.topCenterAsTopLeft(width: Float): Offset {
+ return copy(x = x - width / 2f)
+}
+
+operator fun Offset.plus(amount: Float): Offset = copy(x = x + amount, y = y + amount)
+operator fun Offset.plus(size: Size): Offset = copy(x = x + size.width, y = y + size.height)
+
+operator fun Offset.minus(size: Size): Offset = copy(x = x - size.width, y = y - size.height)
+operator fun Offset.minus(amount: Float): Offset = copy(x = x - amount, y = y - amount)
+
+fun Offset.offsetBy(x: Float = 0f, y: Float = 0f): Offset {
+ return copy(x = this.x + x, y = this.y + y)
+}
+
+fun Offset.angleTo(other: Offset): Float {
+ return Math.toDegrees(atan2(
+ y = y * other.x - x * other.y,
+ x = x * other.x + y * other.y
+ ).toDouble()).toFloat()
+}
diff --git a/shared/src/main/kotlin/extensions/Paint.kt b/shared/src/main/kotlin/extensions/Paint.kt
new file mode 100644
index 0000000..037bad0
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/Paint.kt
@@ -0,0 +1,7 @@
+package org.splitties.compose.oclock.sample.extensions
+
+import androidx.compose.ui.graphics.Paint
+
+fun Paint.setFrom(other: Paint) {
+ asFrameworkPaint().set(other.asFrameworkPaint())
+}
diff --git a/shared/src/main/kotlin/extensions/Path.kt b/shared/src/main/kotlin/extensions/Path.kt
new file mode 100644
index 0000000..ac48961
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/Path.kt
@@ -0,0 +1,37 @@
+@file:Suppress("NOTHING_TO_INLINE")
+
+package org.splitties.compose.oclock.sample.extensions
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Path
+
+inline fun Path.moveTo(position: Offset) {
+ moveTo(x = position.x, y = position.y)
+}
+
+inline fun Path.lineTo(position: Offset) {
+ lineTo(x = position.x, y = position.y)
+}
+
+inline fun Path.quadraticBezierTo(
+ p1: Offset,
+ p2: Offset
+) = quadraticBezierTo(
+ x1 = p1.x,
+ y1 = p1.y,
+ x2 = p2.x,
+ y2 = p2.y
+)
+
+inline fun Path.cubicTo(
+ cp1: Offset,
+ cp2: Offset,
+ point: Offset
+) = cubicTo(
+ x1 = cp1.x,
+ y1 = cp1.y,
+ x2 = cp2.x,
+ y2 = cp2.y,
+ x3 = point.x,
+ y3 = point.y
+)
diff --git a/shared/src/main/kotlin/extensions/PatternsMath.kt b/shared/src/main/kotlin/extensions/PatternsMath.kt
new file mode 100644
index 0000000..9494dcf
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/PatternsMath.kt
@@ -0,0 +1,17 @@
+package org.splitties.compose.oclock.sample.extensions
+
+import kotlin.math.PI
+import kotlin.math.sin
+
+fun circleDiameterInCircularPattern(
+ outerCircle: Float,
+ n: Int
+): Float {
+ val a = sin(PI / n).toFloat()
+ return (a * outerCircle) / (1 + a)
+}
+
+fun circleRadiusInCircularPattern(
+ outerCircle: Float,
+ n: Int
+): Float = circleDiameterInCircularPattern(outerCircle, n) / 2f
diff --git a/shared/src/main/kotlin/extensions/Size.kt b/shared/src/main/kotlin/extensions/Size.kt
new file mode 100644
index 0000000..f579054
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/Size.kt
@@ -0,0 +1,23 @@
+package org.splitties.compose.oclock.sample.extensions
+
+import androidx.compose.ui.geometry.Size
+import kotlin.math.min
+
+
+operator fun Size.minus(pixels: Float): Size {
+ return Size(width - pixels, height - pixels)
+}
+
+fun Size.Companion.square(pixels: Float): Size {
+ return Size(pixels, pixels)
+}
+
+/**
+ * Returns the largest Size that would fit into [other], while keeping the aspect ratio.
+ */
+fun Size.fitIn(other: Size): Size {
+ val maxFactor = other.maxDimension / maxDimension
+ val minFactor = other.minDimension / minDimension
+ val factor = min(maxFactor, minFactor)
+ return this * factor
+}
diff --git a/shared/src/main/kotlin/extensions/SizeDependentState.kt b/shared/src/main/kotlin/extensions/SizeDependentState.kt
new file mode 100644
index 0000000..046ce4a
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/SizeDependentState.kt
@@ -0,0 +1,110 @@
+package org.splitties.compose.oclock.sample.extensions
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.geometry.center
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.unit.Density
+import kotlin.reflect.KProperty
+
+@Composable
+fun rememberStateWithSize(
+ calculation: SizeDependentState.Scope.() -> T
+): SizeDependentState = remember(calculation) {
+ SizeDependentState(calculation)
+}
+
+@Composable
+fun rememberStateWithSize(
+ key1: Any?,
+ calculation: SizeDependentState.Scope.() -> T
+): SizeDependentState = remember(
+ key1 = key1,
+ key2 = calculation
+) {
+ SizeDependentState(calculation)
+}
+
+@Composable
+fun rememberStateWithSize(
+ key1: Any?,
+ key2: Any?,
+ calculation: SizeDependentState.Scope.() -> T
+): SizeDependentState = remember(
+ key1 = key1,
+ key2 = key2,
+ key3 = calculation
+) {
+ SizeDependentState(calculation)
+}
+
+@Composable
+fun rememberStateWithSize(
+ vararg keys: Any?,
+ calculation: SizeDependentState.Scope.() -> T
+): SizeDependentState = remember(*keys, calculation) {
+ SizeDependentState(calculation)
+}
+
+@Stable
+class SizeDependentState(private val calculation: Scope.() -> T) {
+
+ interface Scope : Density {
+ val size: Size
+ val center: Offset
+ }
+
+ context (Scope)
+ operator fun getValue(thisRef: Nothing?, property: KProperty<*>?): T = get()
+
+ context (DrawScope)
+ operator fun getValue(thisRef: Nothing?, property: KProperty<*>?): T = get()
+
+ context (DrawScope)
+ fun provideDelegate(thisRef: Nothing?, property: KProperty<*>?): SizeDependentState {
+ get() // Ensure it's activated if it's declared
+ return this
+ }
+
+ context (Scope)
+ fun provideDelegate(thisRef: Nothing?, property: KProperty<*>?): SizeDependentState {
+ return this
+ }
+
+ context (DrawScope)
+ fun pro(thisRef: Nothing?, property: KProperty<*>): T = get()
+
+ context (Scope)
+ fun get(): T = get(size, density, fontScale)
+
+ context (DrawScope)
+ fun get(): T = get(size, density, fontScale)
+
+ private fun get(size: Size, density: Float, fontScale: Float): T {
+ scope.also {
+ it.size = size
+ it.density = density
+ it.fontScale = fontScale
+ }
+ return value
+ }
+
+ private val state by lazy { derivedStateOf { calculation(scope) } }
+
+ private val value by state
+
+ private val scope = object : Scope {
+ override var density: Float by mutableFloatStateOf(1f)
+ override var fontScale: Float by mutableFloatStateOf(1f)
+ override var size: Size by mutableStateOf(Size.Unspecified)
+ override val center: Offset get() = size.center
+ }
+}
diff --git a/shared/src/main/kotlin/extensions/text/TextOnPathLayoutResult.kt b/shared/src/main/kotlin/extensions/text/TextOnPathLayoutResult.kt
new file mode 100644
index 0000000..b13b66e
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/text/TextOnPathLayoutResult.kt
@@ -0,0 +1,15 @@
+package org.splitties.compose.oclock.sample.extensions.text
+
+import android.graphics.Typeface
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.text.TextLayoutInput
+import androidx.compose.ui.text.TextLayoutResult
+
+class TextOnPathLayoutResult(
+ val internalResult: TextLayoutResult,
+ typeFaceState: State
+) {
+ val layoutInput: TextLayoutInput get() = internalResult.layoutInput
+ val typeface by typeFaceState
+}
diff --git a/shared/src/main/kotlin/extensions/text/TextOnPathMeasurer.kt b/shared/src/main/kotlin/extensions/text/TextOnPathMeasurer.kt
new file mode 100644
index 0000000..3cbd37f
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/text/TextOnPathMeasurer.kt
@@ -0,0 +1,185 @@
+package org.splitties.compose.oclock.sample.extensions.text
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.Placeholder
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.TextMeasurer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontSynthesis
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.font.resolveAsTypeface
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+
+@Immutable
+class TextOnPathMeasurer(
+ private val defaultFontFamilyResolver: FontFamily.Resolver,
+ private val defaultDensity: Density,
+ private val defaultLayoutDirection: LayoutDirection,
+ private val cacheSize: Int = 8
+) {
+ private val internalMeasurer = TextMeasurer(
+ defaultFontFamilyResolver = defaultFontFamilyResolver,
+ defaultDensity = defaultDensity,
+ defaultLayoutDirection = defaultLayoutDirection,
+ cacheSize = cacheSize
+ )
+
+ /**
+ * Creates a [TextLayoutResult] according to given parameters.
+ *
+ * This function supports laying out text that consists of multiple paragraphs, includes
+ * placeholders, wraps around soft line breaks, and might overflow outside the specified size.
+ *
+ * Most parameters for text affect the final text layout. One pixel change in [constraints]
+ * boundaries can displace a word to another line which would cause a chain reaction that
+ * completely changes how text is rendered.
+ *
+ * On the other hand, some attributes only play a role when drawing the created text layout.
+ * For example text layout can be created completely in black color but we can apply
+ * [TextStyle.color] later in draw phase. This also means that animating text color shouldn't
+ * invalidate text layout.
+ *
+ * Thus, [textLayoutCache] helps in the process of converting a set of text layout inputs to
+ * a text layout while ignoring non-layout-affecting attributes. Iterative calls that use the
+ * same input parameters should benefit from substantial performance improvements.
+ *
+ * @param text the text to be laid out
+ * @param style the [TextStyle] to be applied to the whole text
+ * @param overflow How visual overflow should be handled.
+ * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in
+ * the text will be positioned as if there was unlimited horizontal space. If [softWrap] is
+ * false, [overflow] and TextAlign may have unexpected effects.
+ * @param maxLines An optional maximum number of lines for the text to span, wrapping if
+ * necessary. If the text exceeds the given number of lines, it will be truncated according to
+ * [overflow] and [softWrap]. If it is not null, then it must be greater than zero.
+ * @param placeholders a list of [Placeholder]s that specify ranges of text which will be
+ * skipped during layout and replaced with [Placeholder]. It's required that the range of each
+ * [Placeholder] doesn't cross paragraph boundary, otherwise [IllegalArgumentException] is
+ * thrown.
+ * @param constraints how wide and tall the text is allowed to be. [Constraints.maxWidth]
+ * will define the width of the MultiParagraph. [Constraints.maxHeight] helps defining the
+ * number of lines that fit with ellipsis is true. [Constraints.minWidth] defines the minimum
+ * width the resulting [TextLayoutResult.size] will report. [Constraints.minHeight] is no-op.
+ * @param layoutDirection layout direction of the measurement environment. If not specified,
+ * defaults to the value that was given during initialization of this [TextMeasurer].
+ * @param density density of the measurement environment. If not specified, defaults to
+ * the value that was given during initialization of this [TextMeasurer].
+ * @param fontFamilyResolver to be used to load the font given in [SpanStyle]s. If not
+ * specified, defaults to the value that was given during initialization of this [TextMeasurer].
+ * @param skipCache Disables cache optimization if it is passed as true.
+ *
+ * @sample androidx.compose.ui.text.samples.measureTextAnnotatedString
+ */
+ @Stable
+ fun measure(
+ text: AnnotatedString,
+ style: TextStyle = TextStyle.Default,
+ overflow: TextOverflow = TextOverflow.Clip,
+ softWrap: Boolean = true,
+ maxLines: Int = Int.MAX_VALUE,
+ placeholders: List> = emptyList(),
+ constraints: Constraints = Constraints(),
+ layoutDirection: LayoutDirection = this.defaultLayoutDirection,
+ density: Density = this.defaultDensity,
+ fontFamilyResolver: FontFamily.Resolver = this.defaultFontFamilyResolver,
+ skipCache: Boolean = false
+ ): TextOnPathLayoutResult {
+ val internalResult = internalMeasurer.measure(
+ text = text,
+ style = style,
+ overflow = overflow,
+ softWrap = softWrap,
+ maxLines = maxLines,
+ placeholders = placeholders,
+ constraints = constraints,
+ layoutDirection = layoutDirection,
+ density = density,
+ fontFamilyResolver = fontFamilyResolver,
+ skipCache = skipCache
+ )
+ val typeFaceState = fontFamilyResolver.resolveAsTypeface(
+ fontFamily = style.fontFamily,
+ fontWeight = style.fontWeight ?: FontWeight.Normal,
+ fontStyle = style.fontStyle ?: FontStyle.Normal,
+ fontSynthesis = style.fontSynthesis ?: FontSynthesis.All
+ )
+ return TextOnPathLayoutResult(internalResult, typeFaceState)
+ }
+
+ /**
+ * Creates a [TextLayoutResult] according to given parameters.
+ *
+ * This function supports laying out text that consists of multiple paragraphs, includes
+ * placeholders, wraps around soft line breaks, and might overflow outside the specified size.
+ *
+ * Most parameters for text affect the final text layout. One pixel change in [constraints]
+ * boundaries can displace a word to another line which would cause a chain reaction that
+ * completely changes how text is rendered.
+ *
+ * On the other hand, some attributes only play a role when drawing the created text layout.
+ * For example text layout can be created completely in black color but we can apply
+ * [TextStyle.color] later in draw phase. This also means that animating text color shouldn't
+ * invalidate text layout.
+ *
+ * Thus, [textLayoutCache] helps in the process of converting a set of text layout inputs to
+ * a text layout while ignoring non-layout-affecting attributes. Iterative calls that use the
+ * same input parameters should benefit from substantial performance improvements.
+ *
+ * @param text the text to be laid out
+ * @param style the [TextStyle] to be applied to the whole text
+ * @param overflow How visual overflow should be handled.
+ * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in
+ * the text will be positioned as if there was unlimited horizontal space. If [softWrap] is
+ * false, [overflow] and TextAlign may have unexpected effects.
+ * @param maxLines An optional maximum number of lines for the text to span, wrapping if
+ * necessary. If the text exceeds the given number of lines, it will be truncated according to
+ * [overflow] and [softWrap]. If it is not null, then it must be greater than zero.
+ * @param constraints how wide and tall the text is allowed to be. [Constraints.maxWidth]
+ * will define the width of the MultiParagraph. [Constraints.maxHeight] helps defining the
+ * number of lines that fit with ellipsis is true. [Constraints.minWidth] defines the minimum
+ * width the resulting [TextLayoutResult.size] will report. [Constraints.minHeight] is no-op.
+ * @param layoutDirection layout direction of the measurement environment. If not specified,
+ * defaults to the value that was given during initialization of this [TextMeasurer].
+ * @param density density of the measurement environment. If not specified, defaults to
+ * the value that was given during initialization of this [TextMeasurer].
+ * @param fontFamilyResolver to be used to load the font given in [SpanStyle]s. If not
+ * specified, defaults to the value that was given during initialization of this [TextMeasurer].
+ * @param skipCache Disables cache optimization if it is passed as true.
+ *
+ * @sample androidx.compose.ui.text.samples.measureTextStringWithConstraints
+ */
+ @Stable
+ fun measure(
+ text: String,
+ style: TextStyle = TextStyle.Default,
+ overflow: TextOverflow = TextOverflow.Clip,
+ softWrap: Boolean = true,
+ maxLines: Int = Int.MAX_VALUE,
+ constraints: Constraints = Constraints(),
+ layoutDirection: LayoutDirection = this.defaultLayoutDirection,
+ density: Density = this.defaultDensity,
+ fontFamilyResolver: FontFamily.Resolver = this.defaultFontFamilyResolver,
+ skipCache: Boolean = false
+ ): TextOnPathLayoutResult {
+ return measure(
+ text = AnnotatedString(text),
+ style = style,
+ overflow = overflow,
+ softWrap = softWrap,
+ maxLines = maxLines,
+ constraints = constraints,
+ layoutDirection = layoutDirection,
+ density = density,
+ fontFamilyResolver = fontFamilyResolver,
+ skipCache = skipCache
+ )
+ }
+}
diff --git a/shared/src/main/kotlin/extensions/text/TextOnPathMeasurerHelper.kt b/shared/src/main/kotlin/extensions/text/TextOnPathMeasurerHelper.kt
new file mode 100644
index 0000000..5883074
--- /dev/null
+++ b/shared/src/main/kotlin/extensions/text/TextOnPathMeasurerHelper.kt
@@ -0,0 +1,39 @@
+package org.splitties.compose.oclock.sample.extensions.text
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalFontFamilyResolver
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.text.TextMeasurer
+
+/**
+ * This value should reflect the default cache size for TextMeasurer.
+ */
+private val DefaultCacheSize: Int = 8
+
+/**
+ * Creates and remembers a [TextMeasurer]. All parameters that are required for [TextMeasurer]
+ * except [cacheSize] are read from CompositionLocals. Created [TextMeasurer] carries an internal
+ * [androidx.compose.ui.text.TextLayoutCache] with [cacheSize] capacity. Provide 0 for [cacheSize] to opt-out from internal
+ * caching behavior.
+ *
+ * @param cacheSize Capacity of internal cache inside [TextMeasurer]. Size unit is the number of
+ * unique text layout inputs that are measured. Value of this parameter highly depends on the
+ * consumer use case. Provide a cache size that is in line with how many distinct text layouts are
+ * going to be calculated by this measurer repeatedly. If you are animating font attributes, or any
+ * other layout affecting input, cache can be skipped because most repeated measure calls would miss
+ * the cache.
+ */
+@Composable
+fun rememberTextOnPathMeasurer(
+ cacheSize: Int = DefaultCacheSize
+): TextOnPathMeasurer {
+ val fontFamilyResolver = LocalFontFamilyResolver.current
+ val density = LocalDensity.current
+ val layoutDirection = LocalLayoutDirection.current
+
+ return remember(fontFamilyResolver, density, layoutDirection, cacheSize) {
+ TextOnPathMeasurer(fontFamilyResolver, density, layoutDirection, cacheSize)
+ }
+}
diff --git a/shared/src/main/kotlin/watchfaces/AllWatchFaces.kt b/shared/src/main/kotlin/watchfaces/AllWatchFaces.kt
new file mode 100644
index 0000000..5023526
--- /dev/null
+++ b/shared/src/main/kotlin/watchfaces/AllWatchFaces.kt
@@ -0,0 +1,9 @@
+package org.splitties.compose.oclock.sample.watchfaces
+
+import androidx.compose.runtime.Composable
+
+val allWatchFaces: List<@Composable () -> Unit> = listOf(
+ { KotlinFanClock() },
+ { ComposeFanClock() },
+ { BasicAnalogClock() },
+)
diff --git a/shared/src/main/kotlin/watchfaces/BasicAnalogClock.kt b/shared/src/main/kotlin/watchfaces/BasicAnalogClock.kt
new file mode 100644
index 0000000..2a99d83
--- /dev/null
+++ b/shared/src/main/kotlin/watchfaces/BasicAnalogClock.kt
@@ -0,0 +1,141 @@
+package org.splitties.compose.oclock.sample.watchfaces
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Fill
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import org.splitties.compose.oclock.LocalIsAmbient
+import org.splitties.compose.oclock.LocalTime
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.sample.ComposeOClockWatermark
+import org.splitties.compose.oclock.sample.WatchFacePreview
+import org.splitties.compose.oclock.sample.WearPreviewSizesProvider
+import org.splitties.compose.oclock.sample.elements.clockHand
+import org.splitties.compose.oclock.sample.extensions.rotate
+
+@Composable
+fun BasicAnalogClock() {
+ Background()
+ val textBrush = remember {
+ Brush.sweepGradient(
+ 4.5f/8f to Color(0x7C00E5FF),
+ 5.1f/8f to Color(0xFF00E5FF),
+ 6.2f/8f to Color(0xFF00E5FF),
+ 6.5f/8f to Color(0xFFFFFF8D),
+ )
+ }
+ ComposeOClockWatermark(textBrush)
+ HoursHand()
+ MinutesHand()
+ SecondsHand()
+ CenterDot()
+}
+
+@Composable
+private fun Background() {
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ if (isAmbient.not()) drawCircle(kotlinDarkBg)
+ }
+}
+
+@Composable
+private fun HoursHand() {
+ val time = LocalTime.current
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ rotate(degrees = time.hourWithMinutes * 30) {
+ val width = 10.dp.toPx()
+ clockHand(
+ brush = SolidColor(kotlinLogoColors[0]),
+ width = width,
+ height = size.height / 4f,
+ style = if (isAmbient) Stroke(width = 3.dp.toPx()) else Fill,
+ blendMode = BlendMode.Plus
+ )
+ }
+ }
+}
+
+@Composable
+private fun MinutesHand() {
+ val time = LocalTime.current
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ rotate(degrees = time.minutes * 6f) {
+ val width = 10.dp.toPx()
+ clockHand(
+ brush = SolidColor(kotlinLogoColors[1]),
+ width = width,
+ height = size.height * 3 / 8f,
+ style = if (isAmbient) Stroke(width = 3.dp.toPx()) else Fill,
+ blendMode = BlendMode.Plus
+ )
+ }
+ }
+}
+
+@Composable
+private fun SecondsHand() {
+ val time = LocalTime.current
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ if (isAmbient) return@OClockCanvas
+ val color = kotlinLogoColors[2]
+ drawLine(
+ color,
+ start = center,
+ end = center.copy(y = size.height / 32f).rotate(time.seconds * 6f),
+ strokeWidth = 2.dp.toPx(),
+ blendMode = BlendMode.Lighten,
+ cap = StrokeCap.Round
+ )
+ }
+}
+
+@Composable
+private fun CenterDot() {
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ drawCircle(kotlinDarkBg, radius = 6.dp.toPx(), blendMode = BlendMode.Xor)
+ drawCircle(
+ color = if (isAmbient) Color.Black else kotlinLogoColors[2],
+ radius = 5.dp.toPx()
+ )
+ if (isAmbient) {
+ drawCircle(
+ Color.Gray,
+ radius = 5.dp.toPx()
+ )
+ drawCircle(
+ Color.Black,
+ radius = 2.dp.toPx()
+ )
+ }
+ }
+}
+
+private val kotlinDarkBg = Color(0xFF1B1B1B)
+private val kotlinBlue = Color(0xFF7F52FF)
+private val kotlinLogoColors = listOf(
+ kotlinBlue,
+ Color(0xFF_C811E2),
+ Color(0xFF_E54857),
+)
+
+@WatchFacePreview
+@Composable
+private fun BasicAnalogClockPreview(
+ @PreviewParameter(WearPreviewSizesProvider::class) size: Dp
+) = WatchFacePreview(size) {
+ BasicAnalogClock()
+}
diff --git a/shared/src/main/kotlin/watchfaces/ComposeFanClock.kt b/shared/src/main/kotlin/watchfaces/ComposeFanClock.kt
new file mode 100644
index 0000000..eeeb2e5
--- /dev/null
+++ b/shared/src/main/kotlin/watchfaces/ComposeFanClock.kt
@@ -0,0 +1,240 @@
+package org.splitties.compose.oclock.sample.watchfaces
+
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.drawscope.Fill
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.louiscad.composeoclockplayground.shared.R
+import org.splitties.compose.oclock.LocalIsAmbient
+import org.splitties.compose.oclock.LocalTime
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.sample.WatchFacePreview
+import org.splitties.compose.oclock.sample.WearPreviewSizesProvider
+import org.splitties.compose.oclock.sample.elements.SinusoidalMinutes
+import org.splitties.compose.oclock.sample.elements.clockHand
+import org.splitties.compose.oclock.sample.elements.setToHeart
+import org.splitties.compose.oclock.sample.elements.vectors.rememberComposeMultiplatformVectorPainter
+import org.splitties.compose.oclock.sample.extensions.centerAsTopLeft
+import org.splitties.compose.oclock.sample.extensions.drawPainter
+import org.splitties.compose.oclock.sample.extensions.fitIn
+import org.splitties.compose.oclock.sample.extensions.loop
+import org.splitties.compose.oclock.sample.extensions.rememberStateWithSize
+import org.splitties.compose.oclock.sample.extensions.rotate
+
+@Composable
+fun ComposeFanClock() {
+ Background()
+ HeartHourPips()
+ BigComposeLogoBg()
+ HoursHand()
+ MinutesHand()
+ SinusoidalMinutes(
+ interactiveFillBrush = remember { Brush.sweepGradient(someColors) },
+ ambientFillBrush = remember { Brush.sweepGradient(brightColors) }
+ )
+ SecondsHand()
+}
+
+private val brightColors = listOf(
+ Color(0x7C00E5FF),
+ Color(0xFF00E5FF),
+ Color(0xFF64DD17),
+)
+
+private val someColors = listOf(
+ Color(0xFF3D5AFE),
+ Color(0xFF00E5FF),
+ Color(0xFF00B0FF),
+).loop()
+
+@Composable
+private fun BigComposeLogoBg() {
+ val isAmbient by LocalIsAmbient.current
+ val composeMultiplatformLogo = rememberComposeMultiplatformVectorPainter()
+ val composeLogo = painterResource(R.drawable.jetpack_compose)
+ val ambientProgress by animateFloatAsState(
+ if (isAmbient) 1f else 0f
+ )
+ OClockCanvas {
+ val logo = if (isAmbient) composeMultiplatformLogo else composeLogo
+ val s = logo.intrinsicSize.fitIn(size / 2.5f)
+ if (ambientProgress != 0f) {
+ drawPainter(
+ painter = composeMultiplatformLogo, topLeft = center.centerAsTopLeft(s),
+ size = s,
+ alpha = ambientProgress
+ )
+ }
+ if (ambientProgress != 1f) {
+ drawPainter(
+ painter = composeLogo, topLeft = center.centerAsTopLeft(s),
+ size = s,
+ alpha = 1f - ambientProgress
+ )
+ }
+ }
+}
+
+private val ambientTransitionSpec = spring(stiffness = Spring.StiffnessLow)
+
+private val composeMultiplatformColor = Color(red = 66, green = 133, blue = 244)
+
+@Composable
+private fun Background() {
+ val isAmbient by LocalIsAmbient.current
+ val ambientColor = Color.Black
+ val interactiveColor = Color.White
+ val ambientProgress by animateFloatAsState(
+ targetValue = if (isAmbient) 1f else 0f,
+ animationSpec = tween(durationMillis = 500)
+ )
+ OClockCanvas {
+ val showAmbientBg = ambientProgress != 0f
+ val showInteractiveBg = ambientProgress != 1f
+ if (showAmbientBg) {
+ drawCircle(ambientColor)
+ }
+ if (showInteractiveBg) {
+ val radius = (size.minDimension / 2f) * (1 - ambientProgress)
+ drawCircle(interactiveColor, radius = radius)
+ }
+ }
+}
+
+@Composable
+private fun HoursHand() {
+ val time = LocalTime.current
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ rotate(degrees = time.hourWithMinutes * 30) {
+ val width = 10.dp.toPx()
+ clockHand(
+ brush = SolidColor(someColors[2].copy(alpha = 1f)),
+ width = width,
+ height = size.height / 4f,
+ style = if (isAmbient) Stroke(width = 3.dp.toPx()) else Fill,
+ )
+ }
+ }
+}
+
+@Composable
+private fun MinutesHand() {
+ val time = LocalTime.current
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ rotate(degrees = time.minutes * 6f) {
+ val width = 10.dp.toPx()
+ clockHand(
+ brush = SolidColor(someColors[1].copy(alpha = 1f)),
+ width = width,
+ height = size.height * 4 / 10f,
+ style = if (isAmbient) Stroke(width = 3.dp.toPx()) else Fill,
+ )
+ }
+ }
+}
+
+@Composable
+private fun SecondsHand() {
+ val time = LocalTime.current
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ if (isAmbient) return@OClockCanvas
+ val color = brightColors[2]
+ drawLine(
+ color,
+ start = center,
+ end = center.copy(y = size.height / 32f).rotate(time.seconds * 6f),
+ strokeWidth = 2.dp.toPx(),
+ cap = StrokeCap.Round
+ )
+ }
+}
+
+private fun brushForHearts(
+ baseColor: Color,
+ start: Offset,
+ end: Offset
+): Brush = Brush.linearGradient(
+ colors = listOf(
+ baseColor.run { copy(alpha = alpha * .7f) },
+ baseColor.run { copy(alpha = alpha * .3f) }
+ ),
+ start = start,
+ end = end
+)
+
+@Composable
+private fun HeartHourPips() {
+ val heartPath = remember { Path() }
+ val cachedStroke = rememberStateWithSize {
+ Stroke(
+ 1.5f.dp.toPx(),
+ cap = StrokeCap.Butt,
+ join = StrokeJoin.Miter
+ )
+ Stroke(1.5f.dp.toPx(), cap = StrokeCap.Butt, join = StrokeJoin.Miter)
+ }
+ val isAmbient by LocalIsAmbient.current
+ val cachedBrushes = rememberStateWithSize {
+ val padding = size.minDimension / (7f)
+ val side = size.minDimension / 8f
+ val topLeft = Offset(x = center.x, y = side / 2f + padding).centerAsTopLeft(side)
+ val heartSize = Size(side, side)
+ heartPath.setToHeart(topLeft, heartSize)
+ val start = topLeft.run { copy(y = y + side) }
+ val end = topLeft.run { copy(x = x + side) }
+ val interactiveGradient = brushForHearts(Color(0x56949494), start, end)
+ val ambientGradient = brushForHearts(Color(0xFF81D4FA), start, end)
+ interactiveGradient to ambientGradient
+ }
+ val fillAlpha by animateFloatAsState(
+ targetValue = if (isAmbient) 0.3f else 1f,
+ animationSpec = ambientTransitionSpec,
+ label = "minor-hour-pips- fill-alpha"
+ )
+ OClockCanvas {
+ val stroke by cachedStroke
+ val (interactiveBrush, ambientBrush) = cachedBrushes.get()
+ val brush = if (isAmbient) ambientBrush else interactiveBrush
+ val count = 12
+ repeat(count) {
+ rotate(360f * it / count) {
+ drawPath(heartPath, brush = brush, style = stroke)
+ if (fillAlpha != 0f) {
+ drawPath(
+ heartPath,
+ brush = brush,
+ alpha = fillAlpha
+ )
+ }
+ }
+ }
+ }
+}
+
+@WatchFacePreview
+@Composable
+private fun ComposeFanClockPreview(
+ @PreviewParameter(WearPreviewSizesProvider::class) size: Dp
+) = WatchFacePreview(size) {
+ ComposeFanClock()
+}
diff --git a/shared/src/main/kotlin/watchfaces/KotlinFanClock.kt b/shared/src/main/kotlin/watchfaces/KotlinFanClock.kt
new file mode 100644
index 0000000..745e1f9
--- /dev/null
+++ b/shared/src/main/kotlin/watchfaces/KotlinFanClock.kt
@@ -0,0 +1,216 @@
+package org.splitties.compose.oclock.sample.watchfaces
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.drawscope.DrawStyle
+import androidx.compose.ui.graphics.drawscope.Fill
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import org.splitties.compose.oclock.LocalIsAmbient
+import org.splitties.compose.oclock.LocalTime
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.sample.ComposeOClockWatermark
+import org.splitties.compose.oclock.sample.WatchFacePreview
+import org.splitties.compose.oclock.sample.WearPreviewSizesProvider
+import org.splitties.compose.oclock.sample.elements.clockHand
+import org.splitties.compose.oclock.sample.elements.setToHeart
+import org.splitties.compose.oclock.sample.elements.setToKotlinLogo
+import org.splitties.compose.oclock.sample.extensions.centerAsTopLeft
+import org.splitties.compose.oclock.sample.extensions.rememberStateWithSize
+import org.splitties.compose.oclock.sample.extensions.rotate
+
+@Composable
+fun KotlinFanClock() {
+ Background()
+ val textBrush = remember {
+ Brush.sweepGradient(
+ 4.5f/8f to Color(0x7C00E5FF),
+ 5.1f/8f to Color(0xFF00E5FF),
+ 6.2f/8f to Color(0xFF00E5FF),
+ 6.5f/8f to Color(0xFFFFFF8D),
+ )
+ }
+ ComposeOClockWatermark(textBrush)
+ KotlinLogoHourPips()
+ HoursHand()
+ MinutesHand()
+ SecondsHand()
+ CenterDot()
+}
+
+@Composable
+private fun Background() {
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ if (isAmbient.not()) drawCircle(kotlinDarkBg)
+ }
+}
+
+@Composable
+private fun HoursHand() {
+ val time = LocalTime.current
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ rotate(degrees = time.hourWithMinutes * 30) {
+ val width = 10.dp.toPx()
+ clockHand(
+ brush = SolidColor(kotlinLogoColors[0]),
+ width = width,
+ height = size.height / 4f,
+ style = if (isAmbient) Stroke(width = 3.dp.toPx()) else Fill,
+ blendMode = BlendMode.Plus
+ )
+ }
+ }
+}
+
+@Composable
+private fun MinutesHand() {
+ val time = LocalTime.current
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ rotate(degrees = time.minutes * 6f) {
+ val width = 10.dp.toPx()
+ clockHand(
+ brush = SolidColor(kotlinLogoColors[1]),
+ width = width,
+ height = size.height * 3 / 8f,
+ style = if (isAmbient) Stroke(width = 3.dp.toPx()) else Fill,
+ blendMode = BlendMode.Plus
+ )
+ }
+ }
+}
+
+@Composable
+private fun SecondsHand() {
+ val time = LocalTime.current
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ if (isAmbient) return@OClockCanvas
+ val color = kotlinLogoColors[2]
+ drawLine(
+ color,
+ start = center,
+ end = center.copy(y = size.height / 32f).rotate(time.seconds * 6f),
+ strokeWidth = 2.dp.toPx(),
+ blendMode = BlendMode.Lighten,
+ cap = StrokeCap.Round
+ )
+ }
+}
+
+@Composable
+private fun CenterDot() {
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ drawCircle(kotlinDarkBg, radius = 6.dp.toPx(), blendMode = BlendMode.Xor)
+ drawCircle(
+ color = if (isAmbient) Color.Black else kotlinLogoColors[2],
+ radius = 5.dp.toPx()
+ )
+ if (isAmbient) {
+ drawCircle(
+ Color.Gray,
+ radius = 5.dp.toPx()
+ )
+ drawCircle(
+ Color.Black,
+ radius = 2.dp.toPx()
+ )
+ }
+ }
+}
+
+@Composable
+private fun KotlinLogoHourPips() {
+ val filledKotlinLogoPath = remember { Path() }
+ val outlinedKotlinLogoPath = remember { Path() }
+ val heartPath = remember { Path() }
+ val edgePadding = 28.dp
+ val cachedStroke = rememberStateWithSize {
+ Stroke(
+ 1.5f.dp.toPx(),
+ cap = StrokeCap.Butt,
+ join = StrokeJoin.Miter
+ )
+ Stroke(1.5f.dp.toPx(), cap = StrokeCap.Butt, join = StrokeJoin.Miter)
+ }
+ val cachedPipsGradient = rememberStateWithSize {
+ val side = size.minDimension / 12f
+ val topLeft = Offset(x = center.x, y = side / 2f + edgePadding.toPx()).centerAsTopLeft(side)
+ filledKotlinLogoPath.setToKotlinLogo(side, topLeft)
+ outlinedKotlinLogoPath.setToKotlinLogo(
+ side = side,
+ topLeft = topLeft,
+ stroke = cachedStroke.get()
+ )
+ val heartSize = Size(side, side)
+ heartPath.setToHeart(topLeft, heartSize)
+ Brush.linearGradient(
+ kotlinLogoColors,
+ start = topLeft.run { copy(y = y + side) },
+ end = topLeft.run { copy(x = x + side) }
+ )
+ }
+ val isAmbient by LocalIsAmbient.current
+ OClockCanvas {
+ val stroke by cachedStroke
+ val pipsGradient by cachedPipsGradient
+ val kotlinLogoPath: Path
+ val style: DrawStyle
+ if (isAmbient) {
+ style = stroke
+ kotlinLogoPath = outlinedKotlinLogoPath
+ } else {
+ style = Fill
+ kotlinLogoPath = filledKotlinLogoPath
+ }
+ val count = 12
+ repeat(count) {
+ rotate(360f * it / count) {
+ if (it % 3 == 0) {
+ if (isAmbient) {
+ drawPath(kotlinLogoPath, color = kotlinBlue, style = style)
+ } else {
+ drawPath(kotlinLogoPath, brush = pipsGradient, style = style)
+ }
+ } else {
+ if (isAmbient) {
+ drawPath(heartPath, brush = pipsGradient, style = style)
+ } else {
+ drawPath(heartPath, color = kotlinBlue, style = style)
+ }
+ }
+ }
+ }
+ }
+}
+
+private val kotlinDarkBg = Color(0xFF1B1B1B)
+private val kotlinBlue = Color(0xFF7F52FF)
+private val kotlinLogoColors = listOf(
+ kotlinBlue,
+ Color(0xFF_C811E2),
+ Color(0xFF_E54857),
+)
+
+@WatchFacePreview
+@Composable
+private fun KotlinFanClockPreview(
+ @PreviewParameter(WearPreviewSizesProvider::class) size: Dp
+) = WatchFacePreview(size) {
+ KotlinFanClock()
+}
diff --git a/shared/src/main/kotlin/watchfaces/WatchFaceSwitcher.kt b/shared/src/main/kotlin/watchfaces/WatchFaceSwitcher.kt
new file mode 100644
index 0000000..0bf2d1c
--- /dev/null
+++ b/shared/src/main/kotlin/watchfaces/WatchFaceSwitcher.kt
@@ -0,0 +1,51 @@
+package org.splitties.compose.oclock.sample.watchfaces
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import org.splitties.compose.oclock.OClockCanvas
+import org.splitties.compose.oclock.TapEvent
+import org.splitties.compose.oclock.sample.WatchFacePreview
+import org.splitties.compose.oclock.sample.WearPreviewSizesProvider
+
+@Composable
+fun WatchFaceSwitcher() {
+ var index by remember { mutableIntStateOf(0) }
+ OClockCanvas(
+ onTap = { event ->
+ if (event is TapEvent.Up) {
+ val lastIndex = allWatchFaces.lastIndex
+ val touchEdgeWidth = 48.dp.toPx()
+ val isOnLeftEdge = event.position.x <= touchEdgeWidth
+ val isOnRightEdge = isOnLeftEdge.not() &&
+ event.position.x >= size.width - touchEdgeWidth
+ index = when {
+ isOnLeftEdge -> index - 1
+ isOnRightEdge -> index + 1
+ else -> index
+ }.let {
+ when {
+ it < 0 -> lastIndex
+ it > lastIndex -> 0
+ else -> it
+ }
+ }
+ }
+ true
+ }
+ ) { }
+ allWatchFaces[index]()
+}
+
+@WatchFacePreview
+@Composable
+private fun WatchFaceSwitcherPreview(
+ @PreviewParameter(WearPreviewSizesProvider::class) size: Dp
+) = WatchFacePreview(size) {
+ WatchFaceSwitcher()
+}
diff --git a/shared/src/main/res/drawable/jetpack_compose.xml b/shared/src/main/res/drawable/jetpack_compose.xml
new file mode 100644
index 0000000..6ba2b62
--- /dev/null
+++ b/shared/src/main/res/drawable/jetpack_compose.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/shared/src/main/res/values/font_certs.xml b/shared/src/main/res/values/font_certs.xml
new file mode 100644
index 0000000..c0309e9
--- /dev/null
+++ b/shared/src/main/res/values/font_certs.xml
@@ -0,0 +1,18 @@
+
+
+
+ - @array/com_google_android_gms_fonts_certs_dev
+ - @array/com_google_android_gms_fonts_certs_prod
+
+
+ -
+ MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=
+
+
+
+ -
+ MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK
+
+
+
diff --git a/version.txt b/version.txt
new file mode 100644
index 0000000..d3827e7
--- /dev/null
+++ b/version.txt
@@ -0,0 +1 @@
+1.0
diff --git a/versions.properties b/versions.properties
new file mode 100644
index 0000000..c719c06
--- /dev/null
+++ b/versions.properties
@@ -0,0 +1,91 @@
+#### Dependencies and Plugin versions with their available updates.
+#### Generated by `./gradlew refreshVersions` version 0.60.4
+####
+#### Don't manually edit or split the comments that start with four hashtags (####),
+#### they will be overwritten by refreshVersions.
+####
+#### suppress inspection "SpellCheckingInspection" for whole file
+#### suppress inspection "UnusedProperty" for whole file
+
+plugin.android=8.2.0
+
+plugin.de.fayard.refreshVersions=0.60.4
+
+plugin.org.splitties.dependencies-dsl=0.2.0
+
+version.android.tools.desugar_jdk_libs=2.0.4
+
+version.androidx.activity=1.8.2
+## # available=1.9.0-alpha01
+## # available=1.9.0-alpha02
+
+version.androidx.compose=2023.10.01
+
+version.androidx.compose.compiler=1.5.8
+
+version.androidx.compose.foundation=1.6.0
+## # available=1.7.0-alpha01
+
+version.androidx.compose.material3=1.1.2
+## # available=1.2.0-alpha01
+## # available=1.2.0-alpha02
+## # available=1.2.0-alpha03
+## # available=1.2.0-alpha04
+## # available=1.2.0-alpha05
+## # available=1.2.0-alpha06
+## # available=1.2.0-alpha07
+## # available=1.2.0-alpha08
+## # available=1.2.0-alpha09
+## # available=1.2.0-alpha10
+## # available=1.2.0-alpha11
+## # available=1.2.0-alpha12
+## # available=1.2.0-beta01
+## # available=1.2.0-beta02
+## # available=1.2.0-rc01
+
+version.androidx.compose.runtime=1.6.0
+## # available=1.7.0-alpha01
+
+version.androidx.compose.ui=1.6.0
+## # available=1.7.0-alpha01
+
+version.androidx.core=1.12.0
+## # available=1.13.0-alpha01
+## # available=1.13.0-alpha02
+## # available=1.13.0-alpha03
+## # available=1.13.0-alpha04
+
+version.androidx.core-splashscreen=1.0.1
+## # available=1.1.0-alpha01
+## # available=1.1.0-alpha02
+
+version.androidx.lifecycle=2.7.0
+## # available=2.8.0-alpha01
+
+version.androidx.test.espresso=3.5.1
+## # available=3.6.0-alpha01
+## # available=3.6.0-alpha02
+## # available=3.6.0-alpha03
+
+version.androidx.test.ext.junit=1.1.5
+## # available=1.2.0-alpha01
+## # available=1.2.0-alpha02
+## # available=1.2.0-alpha03
+
+version.androidx.wear.compose=1.3.0
+## # available=1.4.0-alpha01
+
+version.androidx.wear.watchface-editor=1.2.1
+
+version.com.google.gms..google-services=4.4.0
+
+version.firebase-crashlytics-gradle=2.9.9
+
+version.google.android.play-services-wearable=18.1.0
+
+version.junit.junit=4.13.2
+
+version.kotlin=1.9.22
+
+version.splitties=3.0.0
+## # available=3.1.0-SNAPSHOT