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-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