diff --git a/.gitignore b/.gitignore index 306b1c32..3f7b3d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .gradle/ .idea/ build/ -local.properties \ No newline at end of file +local.properties +.DS_Store diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 00000000..af160dd4 --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE index 261eeb9e..754dd3ba 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2024 Konyaco Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index ab8464a1..e677d5dc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Compose Fluent +

Compose Fluent logo Compose Fluent

[![License](https://img.shields.io/github/license/Konyaco/compose-fluent-ui)](LICENSE) [![Version](https://img.shields.io/github/v/release/Konyaco/compose-fluent-ui?include_prereleases)](https://github.com/Konyaco/compose-fluent-ui/releases) @@ -23,8 +23,8 @@ Thank you for using our library. We look forward to receiving your feedback and ### Add Dependency ```kts -implementation("com.konyaco:fluent:0.0.1-dev.7") -implementation("com.konyaco:fluent-icons-extended:0.0.1-dev.7") // If you want to use full fluent icons. +implementation("com.konyaco:fluent:0.0.1-dev.8") +implementation("com.konyaco:fluent-icons-extended:0.0.1-dev.8") // If you want to use full fluent icons. ``` ### Example @@ -67,25 +67,45 @@ The copyright of the icon assets (in `com.konyaco.fluent.icons` package) belongs - Layer - [x] Simple Layer - [ ] Real Layer -- [ ] Acrylic +- [x] Acrylic +- [x] Card -### Basic Inputs +### Basic Components -- [x] Button +- [x] Buttons + - [x] Button + - [x] AccentButton + - [x] SubtleButton + - [x] DropdownButton + - [x] HyperlinkButton + - [x] RepeatButton + - [x] ToggleButton + - [x] SplitButton + - [x] ToggleSplitButton +- [x] RadioButton - [x] ToggleSwitch - [x] CheckBox -- [x] RadioButton -- [x] Slider -- [x] DropdownMenu -- [x] TextField + - [ ] TriStateCheckBox +- [x] ComboBox (Simple) - [x] ProgressBar - [x] ProgressRing +- [x] Slider +- [x] TextField + +- [x] ColorPicker +- [x] RatingControl - [ ] Pill Button -- [ ] ComboBox -- [ ] RatingControl -### Basic Components +### Compound Components +- [x] CalendarView (Simple) +- [x] DateTimePicker (Simple) +- [x] Color Picker +- [ ] Navigation + - [x] SideNav + - [ ] BreadcrumbBar + - [ ] Pivot + - [ ] TabView - [ ] Tooltip - [ ] InfoBar - [ ] FilePicker @@ -93,9 +113,9 @@ The copyright of the icon assets (in `com.konyaco.fluent.icons` package) belongs ### Dialogs -- [x] Simple Dialog -- [ ] Compound Dialog (Title, Content, Controls) -- [ ] Flyout +- [x] FluentDialog +- [x] ContentDialog +- [x] Flyout (Simple) ### Animations @@ -106,18 +126,6 @@ The copyright of the icon assets (in `com.konyaco.fluent.icons` package) belongs - [x] Light and Dark theme - [ ] Custom accent color -### Compound Components - -- [ ] Color Picker -- [ ] DateTime Picker -- [ ] Calender -- [ ] Navigation - - [x] SideNav - - [ ] BreadcrumbBar - - [ ] Pivot - - [ ] TabView - -### TODO - +### Accessibility - [ ] Accessibility Semantics diff --git a/TODO.md b/TODO.md index 5dfe09ac..c19814ca 100644 --- a/TODO.md +++ b/TODO.md @@ -19,7 +19,7 @@ - [ ] Improve performance and smoothness. - Layer - - [ ] Eliminate workarounds like `circular`, `cornerRadius` etc. + - [x] Eliminate workarounds like `circular`, `cornerRadius` etc. ## Compound Components diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 00000000..b813645f Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/screenshot.png b/assets/screenshot.png index 889b5bab..e4380cce 100644 Binary files a/assets/screenshot.png and b/assets/screenshot.png differ diff --git a/build-plugin/build.gradle.kts b/build-plugin/build.gradle.kts index dc5f86e5..362af2cb 100644 --- a/build-plugin/build.gradle.kts +++ b/build-plugin/build.gradle.kts @@ -9,7 +9,7 @@ repositories { dependencies { implementation(gradleApi()) - implementation(kotlin("gradle-plugin")) + implementation(kotlin("gradle-plugin", libs.versions.kotlin.get())) } kotlin { diff --git a/build-plugin/settings.gradle.kts b/build-plugin/settings.gradle.kts index e69de29b..fa8bc749 100644 --- a/build-plugin/settings.gradle.kts +++ b/build-plugin/settings.gradle.kts @@ -0,0 +1,7 @@ +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} \ No newline at end of file diff --git a/build-plugin/src/main/java/com/konyaco/fluent/plugin/build/BuildConfig.kt b/build-plugin/src/main/java/com/konyaco/fluent/plugin/build/BuildConfig.kt index 840d097e..301ac7e0 100644 --- a/build-plugin/src/main/java/com/konyaco/fluent/plugin/build/BuildConfig.kt +++ b/build-plugin/src/main/java/com/konyaco/fluent/plugin/build/BuildConfig.kt @@ -8,7 +8,7 @@ object BuildConfig { const val packageName = "$group.fluent" - const val libraryVersion = "0.0.1-dev.7" + const val libraryVersion = "0.0.1-dev.8" object Android { const val compileSdkVersion = 34 diff --git a/build-plugin/src/main/java/com/konyaco/fluent/plugin/build/BuildExtension.kt b/build-plugin/src/main/java/com/konyaco/fluent/plugin/build/BuildExtension.kt new file mode 100644 index 00000000..c2d206f2 --- /dev/null +++ b/build-plugin/src/main/java/com/konyaco/fluent/plugin/build/BuildExtension.kt @@ -0,0 +1,11 @@ +package com.konyaco.fluent.plugin.build + +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +fun KotlinMultiplatformExtension.applyTargets(publish: Boolean = true) { + jvm("desktop") + androidTarget { + if (publish) publishLibraryVariants("release") + } + jvmToolchain(BuildConfig.Jvm.jvmToolchainVersion) +} \ No newline at end of file diff --git a/fluent-icons-core/build.gradle.kts b/fluent-icons-core/build.gradle.kts index 07daec9b..6a675cf1 100644 --- a/fluent-icons-core/build.gradle.kts +++ b/fluent-icons-core/build.gradle.kts @@ -1,9 +1,11 @@ import com.konyaco.fluent.plugin.build.BuildConfig +import com.konyaco.fluent.plugin.build.applyTargets plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose) alias(libs.plugins.android.library) + alias(libs.plugins.ksp) id("maven-publish") signing } @@ -12,24 +14,20 @@ group = BuildConfig.group version = BuildConfig.libraryVersion kotlin { - jvm() - androidTarget { - publishLibraryVariants("release") - } + applyTargets() sourceSets { val commonMain by getting { dependencies { implementation(compose.foundation) } } - val jvmMain by getting { + val desktopMain by getting { dependencies { implementation(compose.desktop.currentOs) } } - val jvmTest by getting + val desktopTest by getting } - jvmToolchain(BuildConfig.Jvm.jvmToolchainVersion) } android { @@ -42,4 +40,15 @@ android { sourceCompatibility = BuildConfig.Jvm.javaVersion targetCompatibility = BuildConfig.Jvm.javaVersion } +} + +dependencies { + val processor = (project(":source-generated-processor")) + add("kspCommonMainMetadata", processor) +} + +ksp { + arg("source.generated.module.name", "FluentIconCore") + arg("source.generated.module.enabled", false.toString()) + arg("source.generated.icon.enabled", true.toString()) } \ No newline at end of file diff --git a/fluent-icons-extended/build.gradle.kts b/fluent-icons-extended/build.gradle.kts index 93956c84..df0b33be 100644 --- a/fluent-icons-extended/build.gradle.kts +++ b/fluent-icons-extended/build.gradle.kts @@ -1,9 +1,11 @@ import com.konyaco.fluent.plugin.build.BuildConfig +import com.konyaco.fluent.plugin.build.applyTargets plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose) alias(libs.plugins.android.library) + alias(libs.plugins.ksp) id("maven-publish") signing } @@ -12,10 +14,7 @@ group = BuildConfig.group version = BuildConfig.libraryVersion kotlin { - jvm() - androidTarget { - publishLibraryVariants("release") - } + applyTargets() sourceSets { val commonMain by getting { dependencies { @@ -24,14 +23,13 @@ kotlin { implementation(project(":fluent-icons-core")) } } - val jvmMain by getting { + val desktopMain by getting { dependencies { implementation(compose.desktop.currentOs) } } - val jvmTest by getting + val desktopTest by getting } - jvmToolchain(BuildConfig.Jvm.jvmToolchainVersion) } android { @@ -44,4 +42,15 @@ android { sourceCompatibility = BuildConfig.Jvm.javaVersion targetCompatibility = BuildConfig.Jvm.javaVersion } +} + +dependencies { + val processor = (project(":source-generated-processor")) + add("kspCommonMainMetadata", processor) +} + +ksp { + arg("source.generated.module.name", "FluentIconExtended") + arg("source.generated.module.enabled", false.toString()) + arg("source.generated.icon.enabled", true.toString()) } \ No newline at end of file diff --git a/fluent/build.gradle.kts b/fluent/build.gradle.kts index 732a1b32..eac4f271 100644 --- a/fluent/build.gradle.kts +++ b/fluent/build.gradle.kts @@ -1,10 +1,11 @@ import com.konyaco.fluent.plugin.build.BuildConfig -import org.jetbrains.compose.compose +import com.konyaco.fluent.plugin.build.applyTargets plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose) alias(libs.plugins.android.library) + alias(libs.plugins.ksp) id("maven-publish") signing } @@ -13,17 +14,15 @@ group = BuildConfig.group version = BuildConfig.libraryVersion kotlin { - jvm() - androidTarget { - publishLibraryVariants("release") - } + applyTargets() sourceSets { val commonMain by getting { dependencies { api(compose.foundation) api(project(":fluent-icons-core")) - implementation(compose("org.jetbrains.compose.ui:ui-util")) + implementation(compose.uiUtil) implementation(libs.uuid) + implementation(libs.haze) } } val commonTest by getting { @@ -34,14 +33,13 @@ kotlin { val androidMain by getting val androidUnitTest by getting val androidInstrumentedTest by getting - val jvmMain by getting { + val desktopMain by getting { dependencies { api(compose.preview) } } - val jvmTest by getting + val desktopTest by getting } - jvmToolchain(BuildConfig.Jvm.jvmToolchainVersion) } android { @@ -54,4 +52,13 @@ android { sourceCompatibility = BuildConfig.Jvm.javaVersion targetCompatibility = BuildConfig.Jvm.javaVersion } +} + +dependencies { + val processor = (project(":source-generated-processor")) + add("kspCommonMainMetadata", processor) +} + +ksp { + arg("source.generated.module.name", project.name) } \ No newline at end of file diff --git a/fluent/src/androidMain/kotlin/com/konyaco/fluent/PlatformCompositionLocalProvider.android.kt b/fluent/src/androidMain/kotlin/com/konyaco/fluent/PlatformCompositionLocalProvider.android.kt new file mode 100644 index 00000000..1a0e2804 --- /dev/null +++ b/fluent/src/androidMain/kotlin/com/konyaco/fluent/PlatformCompositionLocalProvider.android.kt @@ -0,0 +1,8 @@ +package com.konyaco.fluent + +import androidx.compose.runtime.Composable + +@Composable +actual fun PlatformCompositionLocalProvider(content: @Composable () -> Unit) { + content() +} \ No newline at end of file diff --git a/fluent/src/androidMain/kotlin/com/konyaco/fluent/component/FontIcon.android.kt b/fluent/src/androidMain/kotlin/com/konyaco/fluent/component/FontIcon.android.kt new file mode 100644 index 00000000..62808826 --- /dev/null +++ b/fluent/src/androidMain/kotlin/com/konyaco/fluent/component/FontIcon.android.kt @@ -0,0 +1,8 @@ +package com.konyaco.fluent.component + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun ProvideFontIcon(content: @Composable () -> Unit) { + content() +} \ No newline at end of file diff --git a/fluent/src/androidMain/kotlin/com/konyaco/fluent/component/Popup.android.kt b/fluent/src/androidMain/kotlin/com/konyaco/fluent/component/Popup.android.kt new file mode 100644 index 00000000..8079b839 --- /dev/null +++ b/fluent/src/androidMain/kotlin/com/konyaco/fluent/component/Popup.android.kt @@ -0,0 +1,58 @@ +package com.konyaco.fluent.component + +import androidx.compose.runtime.* +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties + +@Composable +internal actual fun Popup( + popupPositionProvider: PopupPositionProvider, + onDismissRequest: (() -> Unit)?, + properties: PopupProperties, + onPreviewKeyEvent: ((KeyEvent) -> Boolean)?, + onKeyEvent: ((KeyEvent) -> Boolean)?, + content: @Composable () -> Unit +) { + val offset = LocalPopupOffset.current + val delegatePopupPositionProvider = remember(popupPositionProvider) { + DelegatePopupPositionProvider({ offset }, popupPositionProvider) + } + androidx.compose.ui.window.Popup(delegatePopupPositionProvider, onDismissRequest, properties) { + CompositionLocalProvider( + LocalPopupOffset provides delegatePopupPositionProvider.currentOffset, + content = content + ) + } +} + +// Workaround for android nested popup position calculate +private val LocalPopupOffset = staticCompositionLocalOf { IntOffset.Zero } + +private class DelegatePopupPositionProvider( + val offset: () -> IntOffset, + val positionProvider: PopupPositionProvider +): PopupPositionProvider { + + var currentOffset by mutableStateOf(IntOffset.Zero) + + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + return positionProvider.calculatePosition( + anchorBounds.translate(offset()), + windowSize, + layoutDirection, + popupContentSize + ).apply { + currentOffset = this + } + } +} \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/Colors.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/Colors.kt index 904a940a..75d6b2d7 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/Colors.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/Colors.kt @@ -1,6 +1,11 @@ package com.konyaco.fluent -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.takeOrElse @@ -106,7 +111,8 @@ data class Stroke( val control: Control, val controlStrong: ControlStrong, val surface: Surface, - val card: Card + val card: Card, + val divider: Divider ) { data class Control( val default: Color, @@ -132,6 +138,10 @@ data class Stroke( val default: Color, val defaultSolid: Color ) + + data class Divider( + val default: Color + ) } data class SubtleFillColors( @@ -142,62 +152,223 @@ data class SubtleFillColors( ) data class Background( + val card: Card, + val smoke: Smoke, val mica: Mica, val layer: Layer, + val layerOnAcrylic: LayerOnAcrylic, + val layerOnMicaBaseAlt: LayerOnMicaBaseAlt, val solid: Solid, - val acrylic: Acrylic + val acrylic: Acrylic, + val accentAcrylic: AccentAcrylic ) { + /** + * Used to create ‘cards’ - content blocks that live on page and layer backgrounds. + */ data class Card( + /** + * Default card color + */ val default: Color, + /** + * Alternate card color: slightly darker + */ val secondary: Color, + /** + * Default card hover and pressed color + */ val tertiary: Color ) + /** + * Used over windows and desktop to block them out as inaccessible. + */ data class Smoke( + /** + * Dims backgrounds behinds dialogs + */ val default: Color ) + /** + * Used on background colors of any material to create layering. + */ data class Layer( + /** + * Content layer color + */ val default: Color, + /** + * Alternate content layer color + */ val alt: Color ) + /** + * Used on background colors of any material to create layering. + */ data class LayerOnAcrylic( + /** + * Content layer color on acrylic surfaces + */ val default: Color ) + /** + * Used for fills on Tab control. + */ data class LayerOnMicaBaseAlt( + /** + * Active Tab Rest + * Content layer + */ val default: Color, + /** + * Active Tab Drag + */ val tertiary: Color, + /** + * Inactive Tab Rest + */ val transparent: Color, + /** + * Inactive Tab Hover + */ val secondary: Color ) + /** + * Solid background colors to place layers, cards, or controls on. + */ data class Solid( + /** + * Used for the bottom most layer of an experience. + */ val base: Color, + /** + * Used for the bottom most layer of an experience. + */ val baseAlt: Color, + /** + * Alternate base color for those who need a darker background color. + */ val secondary: Color, + /** + * Content layer color + */ val tertiary: Color, + /** + * Alt content layer color + */ val quaternary: Color, + /** + * Used for solid default card colors + */ val quinary: Color, + /** + * Used for solid default card color + */ val senary: Color ) + /** + * Mica background colors to place layers, cards, or controls on. + */ data class Mica( + /** + * Used for the bottom most layer of an experience. + * + * Light: #F3F3F3 (FF, 100%), 50% Tint Opacity, 100% Luminosity Opacity + * + * Dark: #202020, 80% Tint Opacity, 100% Luminosity opacity + */ val base: Color, - val baseAlt: Color + /** + * Used for the bottom most layer of an experience. + * + * Fallback Light: Solid Background / Base (#F3F3F3, 100%) + * + * Fallback Dark: Solid Background / Base (#202020, 100%) + */ + val baseFallback: Color, + /** + * Default tab band background color。 + * + * Light: #DADADA(80, 50%), 100% Luminosity Opacity + * + * Dark: #0A0A0A (00, 0%), 100% Luminosity Opacity + */ + val baseAlt: Color, + /** + * Default tab band background color. + * + * Fallback Light: Solid Background / Base Alt (#DADADA, 100%) + * + * Fallback Dark: Solid Background / Base Alt (#0A0A0A, 100%) + */ + val baseAltFallback: Color ) + /** + * Acrylic background colors to place layers, cards, or controls on. + */ data class Acrylic( + /** + * Used for the bottom most layer of an acrylic surface only when the surface will use layers. + * + * Light: #F3F3F3 (FF, 100%), 0% Tint Opacity, 90% Luminosity Opacity + * + * Dark: #202020, 50% TInt Opacity, 96% Luminosity Opacity + */ val base: Color, + /** + * Used for the bottom most layer of an acrylic surface only when the surface will use layers. + * + * Light Fallback: #EEEEEE (FF, 100%) + * + * Dark Fallback: #1C1C1C + */ val baseFallback: Color, + /** + * Default acrylic recipe used for control flyouts and surfaces that live with in the context of an app. + * + * Light: #FCFCFC (FF, 100%), 0% Tint Opacity, 85% Luminosity Opacity + * + * Dark: #2C2C2C, 15% Tint Opacity, 96% Luminosity Opacity + */ val default: Color, + /** + * Default acrylic recipe used for control flyouts and surfaces that live with in the context of an app. + * + * Light Fallback: #F9F9F9 (FF, 100%) + * + * Dark Fallback: #2C2C2C + */ val defaultFallback: Color ) + /** + * Acrylic background colors to place layers, cards, or controls on. + */ data class AccentAcrylic( + /** + * Used for the bottom most layer of an acrylic surface only when the surface will use layers. + * + * Light: Light 3, 80% Tint Opacity, 80% Luminosity Opacity + * + * Dark: Dark 2, 80% Tint Opacity, 80% Luminosity Opacity + */ val base: Color, - val default: Color + val baseFallback: Color, + /** + * Default acrylic recipe used for control flyouts and surfaces that live with in the context of an app. + * + * Light: Light 3, 80% Tint Opacity, 90% Luminosity Opacity + * + * Dark: Dark 1, 80% Tint Opacity, 80% Luminosity Opacity + */ + val default: Color, + val defaultFallback: Color ) } @@ -357,13 +528,23 @@ internal fun generateFillAccentColors(shades: Shades, darkMode: Boolean): FillAc internal fun generateBackground(shades: Shades, darkMode: Boolean): Background = if (darkMode) Background( - mica = Background.Mica(base = Color(0xFF202020), baseAlt = Color(0xFF0A0A0A)), + card = Background.Card( + default = Color(0x0DFFFFFF), + secondary = Color(0x08FFFFFF), + tertiary = Color(0x12FFFFFF) + ), + smoke = Background.Smoke( + default = Color(0x4D000000) + ), layer = Background.Layer(default = Color(0x4C3A3A3A), alt = Color(0x0DFFFFFF)), - acrylic = Background.Acrylic( - base = Color(0xFF202020), - baseFallback = Color(0xFF1C1C1C), - default = Color(0xFF2C2C2C), - defaultFallback = Color(0xFF2C2C2C) + layerOnAcrylic = Background.LayerOnAcrylic( + default = Color(0x09FFFFFF) + ), + layerOnMicaBaseAlt = Background.LayerOnMicaBaseAlt( + default = Color(0x733A3A3A), + tertiary = Color(0xFFF9F9F9), + transparent = Color.Transparent, + secondary = Color(0x0FFFFFFF) ), solid = Background.Solid( base = Color(0xFF202020), @@ -373,16 +554,44 @@ internal fun generateBackground(shades: Shades, darkMode: Boolean): Background = quaternary = Color(0xFF2C2C2C), quinary = Color(0xFF333333), senary = Color(0xFF373737) + ), + mica = Background.Mica( + base = Color(0xFF202020), + baseFallback = Color(0xFF202020), + baseAlt = Color(0x000A0A0A), + baseAltFallback = Color(0xFF0A0A0A), + ), + acrylic = Background.Acrylic( + base = Color(0xFF202020), + baseFallback = Color(0xFF1C1C1C), + default = Color(0xFF2C2C2C), + defaultFallback = Color(0xFF2C2C2C) + ), + accentAcrylic = Background.AccentAcrylic( + base = shades.dark2, + baseFallback = shades.dark2, + default = shades.dark1, + defaultFallback = shades.dark1 ) ) else Background( - mica = Background.Mica(base = Color(0xFFF3F3F3), baseAlt = Color(0xFFDADADA)), + card = Background.Card( + default = Color(0xB3FFFFFF), + secondary = Color(0x80F6F6F6), + tertiary = Color(0xFFFFFFFF) + ), + smoke = Background.Smoke( + default = Color(0x4D000000) + ), layer = Background.Layer(default = Color(0x80FFFFFF), alt = Color(0xFFFFFFFF)), - acrylic = Background.Acrylic( - base = Color(0xFFF3F3F3), - baseFallback = Color(0xFFEEEEEE), - default = Color(0xFFFCFCFC), - defaultFallback = Color(0xFFF9F9F9) + layerOnAcrylic = Background.LayerOnAcrylic( + default = Color(0x40FFFFFF) + ), + layerOnMicaBaseAlt = Background.LayerOnMicaBaseAlt( + default = Color(0xB3FFFFFF), + tertiary = Color(0xFFF9F9F9), + transparent = Color.Transparent, + secondary = Color(0x0A000000) ), solid = Background.Solid( base = Color(0xFFF3F3F3), @@ -392,6 +601,24 @@ internal fun generateBackground(shades: Shades, darkMode: Boolean): Background = quaternary = Color(0xFFFFFFFF), quinary = Color(0xFFFDFDFD), senary = Color(0xFFFFFFFF) + ), + mica = Background.Mica( + base = Color(0xFFF3F3F3), + baseFallback = Color(0xFFF3F3F3), + baseAlt = Color(0xFFDADADA), + baseAltFallback = Color(0xFFDADADA) + ), + acrylic = Background.Acrylic( + base = Color(0xFFF3F3F3), + baseFallback = Color(0xFFEEEEEE), + default = Color(0xFFFCFCFC), + defaultFallback = Color(0xFFF9F9F9) + ), + accentAcrylic = Background.AccentAcrylic( + base = shades.light3, + baseFallback = shades.light3, + default = shades.light3, + defaultFallback = shades.light3 ) ) @@ -417,6 +644,9 @@ internal fun generateStroke(shades: Shades, darkMode: Boolean): Stroke = card = Stroke.Card( default = Color(0x19000000), defaultSolid = Color(0xFF1C1C1C) + ), + divider = Stroke.Divider( + default = Color(0x15FFFFFF) ) ) else Stroke( @@ -440,6 +670,9 @@ internal fun generateStroke(shades: Shades, darkMode: Boolean): Stroke = card = Stroke.Card( default = Color(0x0F000000), defaultSolid = Color(0xFFEBEBEB) + ), + divider = Stroke.Divider( + default = Color(0x14000000) ) ) diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/DefaultFontFamily.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/DefaultFontFamily.kt index e1144ee5..30838f8d 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/DefaultFontFamily.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/DefaultFontFamily.kt @@ -3,5 +3,6 @@ package com.konyaco.fluent import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontFamily +@Deprecated("it will be remove in the future", ReplaceWith("null")) @Composable expect fun defaultFontFamily(): FontFamily? \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/ExperimentalFluentApi.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/ExperimentalFluentApi.kt new file mode 100644 index 00000000..d063dd02 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/ExperimentalFluentApi.kt @@ -0,0 +1,12 @@ +package com.konyaco.fluent + +@RequiresOptIn(message = "This is an experimental fluent API.") +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.FIELD, + AnnotationTarget.PROPERTY_GETTER, +) +@Retention(AnnotationRetention.BINARY) +annotation class ExperimentalFluentApi \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/FluentTheme.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/FluentTheme.kt index 15b1599e..93045cbc 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/FluentTheme.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/FluentTheme.kt @@ -1,33 +1,98 @@ package com.konyaco.fluent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.graphics.Shape +import com.konyaco.fluent.background.AcrylicContainer +import com.konyaco.fluent.background.AcrylicContainerScope +import com.konyaco.fluent.component.ContentDialogHost +import com.konyaco.fluent.component.ContentDialogHostState +import com.konyaco.fluent.component.LocalContentDialog +import com.konyaco.fluent.component.ProvideFontIcon +@ExperimentalFluentApi @Composable fun FluentTheme( colors: Colors = FluentTheme.colors, typography: Typography = FluentTheme.typography, -// defaultFontFamily: FontFamily? = defaultFontFamily(), + useAcrylicPopup: Boolean = LocalAcrylicPopupEnabled.current, + compactMode: Boolean = true, + content: @Composable () -> Unit +) { + val contentDialogHostState = remember { ContentDialogHostState() } + AcrylicContainer { + CompositionLocalProvider( + LocalAcrylicPopupEnabled provides useAcrylicPopup, + LocalColors provides colors, + LocalTypography provides typography, + LocalWindowAcrylicContainer provides this, + LocalTextSelectionColors provides TextSelectionColors( + colors.text.onAccent.primary, + colors.fillAccent.selectedTextBackground.copy(0.4f) + ), + LocalContentDialog provides contentDialogHostState, + LocalCompactMode provides compactMode + ) { + ContentDialogHost(contentDialogHostState) + Box(modifier = Modifier.behindAcrylic()) { + ProvideFontIcon { + PlatformCompositionLocalProvider(content) + } + } + } + } +} + +/** + * Uses for override theme configuration + */ +@ExperimentalFluentApi +@Composable +fun FluentThemeConfiguration( + colors: Colors = FluentTheme.colors, + typography: Typography = FluentTheme.typography, + useAcrylicPopup: Boolean = LocalAcrylicPopupEnabled.current, + compactMode: Boolean = LocalCompactMode.current, + contentDialogHostState: ContentDialogHostState = LocalContentDialog.current, content: @Composable () -> Unit ) { CompositionLocalProvider( + LocalAcrylicPopupEnabled provides useAcrylicPopup, LocalColors provides colors, - LocalTypography provides typography/*(defaultFontFamily?.let { - Typography( - caption = typography.caption.copy(fontFamily = defaultFontFamily), - body = typography.body.copy(fontFamily = defaultFontFamily), - bodyStrong = typography.bodyStrong.copy(fontFamily = defaultFontFamily), - bodyLarge = typography.bodyLarge.copy(fontFamily = defaultFontFamily), - subtitle = typography.subtitle.copy(fontFamily = defaultFontFamily), - title = typography.title.copy(fontFamily = defaultFontFamily), - titleLarge = typography.titleLarge.copy(fontFamily = defaultFontFamily), - display = typography.display.copy(fontFamily = defaultFontFamily), - ) - } ?: typography)*/, + LocalTypography provides typography, + LocalTextSelectionColors provides TextSelectionColors( + colors.text.onAccent.primary, + colors.fillAccent.selectedTextBackground.copy(0.4f) + ), + LocalCompactMode provides compactMode, + LocalContentDialog provides contentDialogHostState, + content = content + ) +} + +@OptIn(ExperimentalFluentApi::class) +@Composable +fun FluentTheme( + colors: Colors = FluentTheme.colors, + typography: Typography = FluentTheme.typography, + content: @Composable () -> Unit +) { + FluentTheme(colors, typography, useAcrylicPopup = false, compactMode = true, content) +} + +@Composable +fun CompactMode(enabled: Boolean = true, content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalCompactMode provides enabled, content = content ) } @@ -45,6 +110,32 @@ object FluentTheme { internal val LocalColors = staticCompositionLocalOf { lightColors() } +@ExperimentalFluentApi +internal val LocalWindowAcrylicContainer = + staticCompositionLocalOf { EmptyAcrylicContainerScope() } + +internal val LocalCompactMode = staticCompositionLocalOf { true } + +@OptIn(ExperimentalFluentApi::class) +private class EmptyAcrylicContainerScope : AcrylicContainerScope { + override fun Modifier.behindAcrylic(): Modifier { + return this + } + + override fun Modifier.acrylicOverlay(tint: Color, shape: Shape, enabled: () -> Boolean): Modifier { + return this + } + + override fun Modifier.align(alignment: Alignment): Modifier { + return this + } + + override fun Modifier.matchParentSize(): Modifier { + return this + } +} + +internal val LocalAcrylicPopupEnabled = staticCompositionLocalOf { true } fun lightColors(accent: Color = Color(0xFF0078D4)): Colors = Colors(generateShades(accent), false) fun darkColors(accent: Color = Color(0xFF0078D4)): Colors = Colors(generateShades(accent), true) \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/PlatformCompositionLocalProvider.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/PlatformCompositionLocalProvider.kt new file mode 100644 index 00000000..1669aa64 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/PlatformCompositionLocalProvider.kt @@ -0,0 +1,7 @@ +package com.konyaco.fluent + +import androidx.compose.runtime.Composable + +@Composable +expect fun PlatformCompositionLocalProvider(content: @Composable () -> Unit) + diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/Typography.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/Typography.kt index 89309ecc..216dd116 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/Typography.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/Typography.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.unit.sp internal val LocalTypography = staticCompositionLocalOf { Typography( caption = TextStyle( - fontWeight = FontWeight.Light, + fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp ), body = TextStyle( diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/background/Acrylic.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/background/Acrylic.kt index 257ba4e2..c5a2062c 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/background/Acrylic.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/background/Acrylic.kt @@ -1,57 +1,96 @@ package com.konyaco.fluent.background -/* -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.res.ResourceLoader -import androidx.compose.ui.res.loadImageBitmap -import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp +import com.konyaco.fluent.ExperimentalFluentApi import com.konyaco.fluent.FluentTheme -import kotlin.math.ceil +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.haze +import dev.chrisbanes.haze.hazeChild -@OptIn(ExperimentalComposeUiApi::class) +@ExperimentalFluentApi @Composable -fun Acrylic( +fun AcrylicContainerScope.Acrylic( modifier: Modifier = Modifier, + enabled: () -> Boolean = { true }, + tint: Color = AcrylicDefaults.tintColor, + shape: Shape = AcrylicDefaults.shape, + border: BorderStroke? = null, content: @Composable () -> Unit ) { - val img = remember { - loadImageBitmap(ResourceLoader.Default.load("NoiseAsset_256.png")) - } Layer( - outsideBorder = true, - shape = RoundedCornerShape(8.dp), - color = FluentTheme.colors.background.acrylic.base, - border = BorderStroke(1.dp, FluentTheme.colors.stroke.surface.default), - cornerRadius = 8.dp + modifier = modifier.acrylicOverlay(tint = tint, shape = shape, enabled = enabled), + shape = shape, + color = if (enabled()) Color.Transparent else FluentTheme.colors.background.layer.default, + border = border, + backgroundSizing = BackgroundSizing.InnerBorderEdge ) { - Box(Modifier.drawWithContent { - val w = ceil(size.width / img.width).toInt() - val h = ceil(size.height / img.height).toInt() - - repeat(h) { y -> - repeat(w) { x -> - drawImage( - image = img, - dstOffset = IntOffset(x * img.width, y * img.height), - blendMode = BlendMode.Luminosity, - alpha = 0.02f, - filterQuality = FilterQuality.None - ) - } - } - drawContent() - }.then(modifier)) { - content() - } + content() + } +} + +@ExperimentalFluentApi +@Composable +fun AcrylicContainer( + content: @Composable AcrylicContainerScope.() -> Unit +) { + Box { + val scope = remember(this) { AcrylicContainerScopeImpl(this) } + scope.content() + } +} + +@OptIn(ExperimentalFluentApi::class) +private class AcrylicContainerScopeImpl(boxScope: BoxScope): AcrylicContainerScope, BoxScope by boxScope { + private val hazeState = HazeState() + + override fun Modifier.behindAcrylic(): Modifier { + return then(Modifier.haze(state = hazeState)) } -}*/ + + override fun Modifier.acrylicOverlay(tint: Color, shape: Shape, enabled: () -> Boolean): Modifier { + return then(if (enabled()) { + Modifier.hazeChild( + state = hazeState, + shape = shape, + style = HazeStyle( + tint = tint, + noiseFactor = AcrylicDefaults.noise, + blurRadius = AcrylicDefaults.blurRadius + ) + ) + } else { + Modifier + }) + } +} + +@ExperimentalFluentApi +interface AcrylicContainerScope: BoxScope { + fun Modifier.behindAcrylic(): Modifier + + fun Modifier.acrylicOverlay(tint: Color, shape: Shape, enabled: () -> Boolean = { true }): Modifier + +} + +internal object AcrylicDefaults { + + const val noise = 0.02f + + val blurRadius = 70.dp + + val tintColor: Color + @Composable + get() = FluentTheme.colors.background.acrylic.default.copy(0.8f) + + val shape = RectangleShape +} \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/background/Layer.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/background/Layer.kt index efd7d39d..860d5962 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/background/Layer.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/background/Layer.kt @@ -5,51 +5,105 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CutCornerShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.translate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.constrainHeight -import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.offset import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.LocalContentAlpha import com.konyaco.fluent.LocalContentColor import com.konyaco.fluent.ProvideTextStyle -import kotlin.math.ceil -import kotlin.math.floor +import kotlin.math.sqrt +/** + * Defines constants that specify how far an element's background extends in relation to the element's border. + */ +enum class BackgroundSizing { + /** + * The element's background extends to the inner edge of the border, but does not extend under the border. + */ + InnerBorderEdge, + + /** + * The element's background extends under the border to its outer edge, and is visible if the border is transparent. + */ + OuterBorderEdge +} + +@Deprecated( + message = "Use backgroundSizing", + replaceWith = ReplaceWith( + expression = "Layer(modifier=modifier,shape=shape,color=color,contentColor=contentColor,border=border,backgroundSizing=if (outsideBorder) BackgroundSizing.InnerBorderEdge else BackgroundSizing.OuterBorderEdge,elevation=elevation,content=content)", + imports = arrayOf("com.konyaco.fluent.background.BackgroundSizing") + ) +) @Composable fun Layer( modifier: Modifier = Modifier, - shape: Shape = RoundedCornerShape(4.dp), + shape: Shape = RoundedCornerShape(size = 4.dp), color: Color = FluentTheme.colors.background.layer.default, contentColor: Color = FluentTheme.colors.text.text.primary, border: BorderStroke? = BorderStroke(1.dp, FluentTheme.colors.stroke.card.default), outsideBorder: Boolean = false, - cornerRadius: Dp = 4.dp, elevation: Dp = 0.dp, - circular: Boolean = false, // If layer is circular, use this to remove 1px gap + content: @Composable () -> Unit +) { + Layer( + modifier = modifier, + shape = shape, + color = color, + contentColor = contentColor, + border = border, + elevation = elevation, + backgroundSizing = if (outsideBorder) { + BackgroundSizing.InnerBorderEdge + } else { + BackgroundSizing.OuterBorderEdge + }, + content = content + ) +} + +@Composable +fun Layer( + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(size = 4.dp), + color: Color = FluentTheme.colors.background.layer.default, + contentColor: Color = FluentTheme.colors.text.text.primary, + border: BorderStroke? = BorderStroke(1.dp, FluentTheme.colors.stroke.card.default), + backgroundSizing: BackgroundSizing, + elevation: Dp = 0.dp, content: @Composable () -> Unit ) { ProvideTextStyle(FluentTheme.typography.body.copy(color = contentColor)) { - CompositionLocalProvider(LocalContentColor provides contentColor) { - val innerShape = remember(shape, outsideBorder) { - if (shape is RoundedCornerShape && shape != CircleShape && outsideBorder) - RoundedCornerShape((cornerRadius - 1.dp).coerceIn(0.dp, Dp.Infinity)) - else shape - } + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalContentAlpha provides contentColor.alpha + ) { Box( - modifier.layer(elevation, shape, border, outsideBorder, circular, color, innerShape), // TODO: A better way to set content corner + modifier = modifier.layer( + elevation, + shape, + border, + backgroundSizing, + color + ), propagateMinConstraints = true ) { content() @@ -58,24 +112,99 @@ fun Layer( } } -private fun Modifier.layer(elevation: Dp, shape: Shape, border: BorderStroke?, outsideBorder: Boolean, circular: Boolean, color: Color, innerShape: Shape) = this.shadow(elevation, shape, clip = false) - .then(if (border != null) Modifier.border(border, shape) else Modifier) - .layout { measurable, constraints -> - // TODO: A better way to implement outside border - val paddingValue = when { - outsideBorder && circular -> calcCircularPadding(this) - outsideBorder -> calcPadding(this) - else -> 0.dp - }.roundToPx() - val placeable = measurable.measure(constraints.offset(-paddingValue * 2, -paddingValue * 2)) - val width = constraints.constrainWidth(placeable.width + paddingValue * 2) - val height = constraints.constrainHeight(placeable.height + paddingValue * 2) - layout(width, height) { - placeable.place(paddingValue, paddingValue) +private fun Modifier.layer( + elevation: Dp, + shape: Shape, + border: BorderStroke?, + backgroundSizing: BackgroundSizing, + color: Color +) = then( + Modifier + .shadow(elevation = elevation, shape = shape, clip = false) + .then( + if (border != null) { + val backgroundShape = + if (backgroundSizing == BackgroundSizing.InnerBorderEdge && shape is CornerBasedShape) { + BackgroundPaddingShape(shape) + } else { + shape + } + Modifier.border(border, shape) + .background(color, backgroundShape) + } else { + Modifier.background(color, shape) + } + ) + .clip(shape) +) + +/** + * keep padding for background + */ +@Immutable +@JvmInline +private value class BackgroundPaddingShape(private val borderShape: CornerBasedShape) : Shape { + + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + return with(density) { + val circular = borderShape == CircleShape + val paddingPx = when { + circular -> calcCircularPadding(density) + else -> calcPadding(density) + }.toPx() + createInnerOutline(size, density, layoutDirection, paddingPx) } } - .background(color = color, shape = innerShape) - .clip(shape = innerShape) + + /** + * Fork from [CornerBasedShape.createOutline], add padding to corner size and outline rect size. + */ + private fun createInnerOutline(size: Size, density: Density, layoutDirection: LayoutDirection, paddingPx: Float) = + borderShape.run { + val cornerPaddingPx = if (this is CutCornerShape) { + /** padding for cut corner shape */ + (paddingPx / sqrt(2f)).toInt().toFloat() + } else { + paddingPx + } + val innerSize = Size(size.width - 2 * paddingPx, size.height - 2 * paddingPx) + /** add padding to corner size */ + var topStart = (borderShape.topStart.toPx(size, density) - cornerPaddingPx).coerceAtLeast(0f) + var topEnd = (borderShape.topEnd.toPx(size, density) - cornerPaddingPx).coerceAtLeast(0f) + var bottomEnd = (borderShape.bottomEnd.toPx(size, density) - cornerPaddingPx).coerceAtLeast(0f) + var bottomStart = (borderShape.bottomStart.toPx(size, density) - cornerPaddingPx).coerceAtLeast(0f) + val minDimension = innerSize.minDimension + if (topStart + bottomStart > minDimension) { + val scale = minDimension / (topStart + bottomStart) + topStart *= scale + bottomStart *= scale + } + if (topEnd + bottomEnd > minDimension) { + val scale = minDimension / (topEnd + bottomEnd) + topEnd *= scale + bottomEnd *= scale + } + require(topStart >= 0.0f && topEnd >= 0.0f && bottomEnd >= 0.0f && bottomStart >= 0.0f) { + "Corner size in Px can't be negative(topStart = $topStart, topEnd = $topEnd, " + + "bottomEnd = $bottomEnd, bottomStart = $bottomStart)!" + } + /** add padding to outline rect size */ + val oldOutline = createOutline( + size = innerSize, + topStart = topStart, + topEnd = topEnd, + bottomEnd = bottomEnd, + bottomStart = bottomStart, + layoutDirection = layoutDirection + ) + /** translate outline to the actual rect bounds */ + when (oldOutline) { + is Outline.Rectangle -> Outline.Rectangle(oldOutline.rect.translate(Offset(paddingPx, paddingPx))) + is Outline.Rounded -> Outline.Rounded(oldOutline.roundRect.translate(Offset(paddingPx, paddingPx))) + is Outline.Generic -> Outline.Generic(oldOutline.path.apply { translate(Offset(paddingPx, paddingPx)) }) + } + } +} /** * This is a workaround solution to eliminate 1 pixel gap @@ -85,14 +214,11 @@ private fun Modifier.layer(elevation: Dp, shape: Shape, border: BorderStroke?, o private fun calcPadding(density: Density): Dp { val remainder = density.density % 1f - return when { - remainder == 0f -> 1.dp - remainder < 0.5f -> with(density) { -// (1.dp.toPx() + 1).toDp() - ceil(1.dp.toPx()).toDp() + return with(density) { + when { + remainder == 0f -> 1.dp + else -> (1.dp.toPx() - remainder + 1).toDp() } - - else -> 1.dp } } @@ -101,7 +227,7 @@ private fun calcCircularPadding(density: Density): Dp { val remainder = density.density % 1f return with(density) { - if (remainder == 0f) (1.dp.toPx() - 1f).toDp() // floor(1.dp.toPx() - 0.5f).toDp() - else floor(1.dp.toPx()).toDp() + if (remainder == 0f) 1.dp + else (1.dp.toPx() - remainder + 1).toDp() } } \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Button.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Button.kt index 44267eeb..b670eea7 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Button.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Button.kt @@ -1,33 +1,74 @@ package com.konyaco.fluent.component import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.konyaco.fluent.FluentTheme import com.konyaco.fluent.animation.FluentDuration import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.BackgroundSizing import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.regular.ChevronDown +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.VisualState +import com.konyaco.fluent.scheme.VisualStateScheme +import com.konyaco.fluent.scheme.collectVisualState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch -@Immutable -data class ButtonColors( - val default: ButtonColor, - val hovered: ButtonColor, - val pressed: ButtonColor, - val disabled: ButtonColor +@Deprecated( + message = "use ButtonColorScheme instead.", + replaceWith = ReplaceWith("ButtonColorScheme", imports = arrayOf("com.konyaco.fluent.component.ButtonColorScheme")) ) +typealias ButtonColors = ButtonColorScheme + +typealias ButtonColorScheme = PentaVisualScheme @Immutable data class ButtonColor( @@ -41,12 +82,23 @@ fun Button( onClick: () -> Unit, modifier: Modifier = Modifier, disabled: Boolean = false, - buttonColors: ButtonColors = buttonColors(), + buttonColors: VisualStateScheme = ButtonDefaults.buttonColors(), interaction: MutableInteractionSource = remember { MutableInteractionSource() }, iconOnly: Boolean = false, + contentArrangement: Arrangement.Horizontal = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), content: @Composable RowScope.() -> Unit ) { - Button(modifier, interaction, disabled, buttonColors, false, onClick, iconOnly, content) + Button( + modifier, + interaction, + disabled, + buttonColors, + false, + onClick, + iconOnly, + contentArrangement, + content + ) } @Composable @@ -54,12 +106,23 @@ fun AccentButton( onClick: () -> Unit, modifier: Modifier = Modifier, disabled: Boolean = false, - buttonColors: ButtonColors = accentButtonColors(), + buttonColors: VisualStateScheme = ButtonDefaults.accentButtonColors(), interaction: MutableInteractionSource = remember { MutableInteractionSource() }, iconOnly: Boolean = false, + contentArrangement: Arrangement.Horizontal = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), content: @Composable RowScope.() -> Unit ) { - Button(modifier, interaction, disabled, buttonColors, true, onClick, iconOnly, content) + Button( + modifier, + interaction, + disabled, + buttonColors, + true, + onClick, + iconOnly, + contentArrangement, + content + ) } @Composable @@ -67,162 +130,507 @@ fun SubtleButton( onClick: () -> Unit, modifier: Modifier = Modifier, disabled: Boolean = false, - buttonColors: ButtonColors = subtleButtonColors(), + buttonColors: VisualStateScheme = ButtonDefaults.subtleButtonColors(), interaction: MutableInteractionSource = remember { MutableInteractionSource() }, iconOnly: Boolean = false, + contentArrangement: Arrangement.Horizontal = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), content: @Composable RowScope.() -> Unit ) { - Button(modifier, interaction, disabled, buttonColors, true, onClick, iconOnly, content) + Button( + modifier, + interaction, + disabled, + buttonColors, + true, + onClick, + iconOnly, + contentArrangement, + content + ) } @Composable -private fun Button( - modifier: Modifier, - interaction: MutableInteractionSource, - disabled: Boolean, - buttonColors: ButtonColors, - accentButton: Boolean, +fun HyperlinkButton( + navigateUri: String, + modifier: Modifier = Modifier, + disabled: Boolean = false, + buttonColors: VisualStateScheme = ButtonDefaults.hyperlinkButtonColors(), + interaction: MutableInteractionSource = remember { MutableInteractionSource() }, + iconOnly: Boolean = false, + content: @Composable RowScope.() -> Unit +) { + val uriHandler = LocalUriHandler.current + HyperlinkButton( + modifier = modifier, + disabled = disabled, + buttonColors = buttonColors, + interaction = interaction, + iconOnly = iconOnly, + content = content, + onClick = { uriHandler.openUri(navigateUri) } + ) +} + +@Composable +fun HyperlinkButton( onClick: () -> Unit, - iconOnly: Boolean, + modifier: Modifier = Modifier, + disabled: Boolean = false, + buttonColors: VisualStateScheme = ButtonDefaults.hyperlinkButtonColors(), + interaction: MutableInteractionSource = remember { MutableInteractionSource() }, + iconOnly: Boolean = false, + contentArrangement: Arrangement.Horizontal = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), content: @Composable RowScope.() -> Unit ) { - val hovered by interaction.collectIsHoveredAsState() - val pressed by interaction.collectIsPressedAsState() - - val buttonColor = when { - disabled -> buttonColors.disabled - pressed -> buttonColors.pressed - hovered -> buttonColors.hovered - else -> buttonColors.default - } + Button( + modifier = modifier.pointerHoverIcon(if (!disabled) PointerIcon.Hand else PointerIcon.Default), + interaction = interaction, + disabled = disabled, + buttonColors = buttonColors, + true, + onClick = onClick, + iconOnly = iconOnly, + contentArrangement = contentArrangement, + content = content + ) +} - val fillColor by animateColorAsState( - buttonColor.fillColor, - animationSpec = tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RepeatButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + delay: Long = 200, + interval: Long = 50, + disabled: Boolean = false, + buttonColors: VisualStateScheme = ButtonDefaults.buttonColors(), + interaction: MutableInteractionSource = remember { MutableInteractionSource() }, + iconOnly: Boolean = false, + content: @Composable RowScope.() -> Unit +) { + val pressed = interaction.collectIsPressedAsState() + val scope = rememberCoroutineScope() + + Button( + modifier = modifier.combinedClickable( + interactionSource = interaction, + indication = null, + enabled = !disabled, + onClick = onClick, + onLongClick = { + onClick() + scope.launch { + delay(delay) + do { + onClick() + delay(interval) + } while (pressed.value) + } + }, + onDoubleClick = { + onClick() + onClick() + } + ), + interaction = interaction, + disabled = disabled, + buttonColors = buttonColors, + accentButton = false, + onClick = null, + iconOnly = iconOnly, + content = content, + contentArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) ) +} - val contentColor by animateColorAsState( - buttonColor.contentColor, - animationSpec = tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) +@Composable +fun DropDownButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + disabled: Boolean = false, + buttonColors: VisualStateScheme = ButtonDefaults.buttonColors(), + interaction: MutableInteractionSource = remember { MutableInteractionSource() }, + iconOnly: Boolean = false, + contentArrangement: Arrangement.Horizontal = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + content: @Composable RowScope.() -> Unit +) { + Button( + onClick = onClick, + disabled = disabled, + buttonColors = buttonColors, + interaction = interaction, + iconOnly = iconOnly, + modifier = modifier, + contentArrangement = contentArrangement + ) { + content() + AnimatedDropDownIcon(interaction) + } +} + +@Composable +fun ToggleButton( + checked: Boolean, + onCheckedChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, + disabled: Boolean = false, + colors: VisualStateScheme = if(checked) { + ButtonDefaults.accentButtonColors() + } else { + ButtonDefaults.buttonColors() + }, + interaction: MutableInteractionSource = remember { MutableInteractionSource() }, + iconOnly: Boolean = false, + outsideBorder: Boolean = !checked, + content: @Composable RowScope.() -> Unit +) { + Button( + modifier = modifier.selectable( + selected = checked, + interactionSource = interaction, + indication = null, + onClick = { onCheckedChanged(!checked) }, + role = Role.Checkbox, + enabled = !disabled + ), + interaction = interaction, + disabled = disabled, + buttonColors = colors, + accentButton = !outsideBorder, + onClick = null, + iconOnly = iconOnly, + content = content, + contentArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) ) +} +@Composable +fun SplitButton( + flyoutClick: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + buttonColors: VisualStateScheme = ButtonDefaults.buttonColors(), + accentButton: Boolean = false, + disabled: Boolean = false, + content: @Composable RowScope.() -> Unit +) { + val currentColor = if (!disabled) { + buttonColors.schemeFor(VisualState.Default) + } else { + buttonColors.schemeFor(VisualState.Disabled) + } + val borderBrush = currentColor.borderBrush + val endContentOffset = remember { mutableStateOf(0f) } Layer( + modifier = modifier.border(BorderStroke(buttonBorderStrokeWidth, currentColor.borderBrush), buttonShape) + .drawWithCache { + /* draw split broder */ + val path = Path() + val strokeWidth = buttonBorderStrokeWidth.toPx() + path.moveTo(endContentOffset.value, strokeWidth) + path.lineTo(endContentOffset.value, size.height - 2 * strokeWidth) + path.close() + onDrawWithContent { + drawContent() + drawPath(path, borderBrush, style = Stroke(strokeWidth)) + } + }, + shape = buttonShape, + color = Color.Transparent, + contentColor = currentColor.contentColor, + /* workaround for outside border padding */ + border = null, + backgroundSizing = if (!accentButton + ) BackgroundSizing.InnerBorderEdge else BackgroundSizing.OuterBorderEdge + ) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + val contentInteraction = remember { MutableInteractionSource() } + ButtonLayer( + shape = RectangleShape, + buttonColors = buttonColors, + interaction = contentInteraction, + disabled = disabled, + accentButton = false, + displayBorder = false, + modifier = Modifier.clickable( + interactionSource = contentInteraction, + indication = null, + onClick = onClick, + enabled = !disabled + ).heightIn(buttonMinHeight) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + content() + } + } + val dropDownInteraction = remember { MutableInteractionSource() } + ButtonLayer( + shape = RectangleShape, + buttonColors = buttonColors, + interaction = dropDownInteraction, + disabled = disabled, + accentButton = false, + displayBorder = false, + modifier = Modifier.clickable( + interactionSource = dropDownInteraction, + indication = null, + onClick = flyoutClick, + enabled = !disabled + ).fillMaxHeight().onGloballyPositioned { + endContentOffset.value = it.positionInParent().x.toInt().toFloat() + }, + ) { + Box( + modifier = Modifier.fillMaxHeight().padding(start = 1.dp).size(32.dp), + contentAlignment = Alignment.Center + ) { + AnimatedDropDownIcon(dropDownInteraction) + } + } + } + } +} + +@Composable +fun ToggleSplitButton( + flyoutClick: () -> Unit, + onClick: () -> Unit, + checked: Boolean, + modifier: Modifier = Modifier, + colors: VisualStateScheme = if(checked) { + ButtonDefaults.accentButtonColors() + } else { + ButtonDefaults.buttonColors() + }, + accentButton: Boolean = checked, + disabled: Boolean = false, + content: @Composable RowScope.() -> Unit +) { + SplitButton( + flyoutClick = flyoutClick, + onClick = onClick, + modifier = modifier, + buttonColors = colors, + accentButton = accentButton, + disabled = disabled, + content = content + ) +} + +@Composable +private fun Button( + modifier: Modifier, + interaction: MutableInteractionSource, + disabled: Boolean, + buttonColors: VisualStateScheme, + accentButton: Boolean, + onClick: (() -> Unit)?, + iconOnly: Boolean, + contentArrangement: Arrangement.Horizontal, + content: @Composable (RowScope.() -> Unit) +) { + ButtonLayer( + shape = buttonShape, + displayBorder = true, + buttonColors = buttonColors, + interaction = interaction, + disabled = disabled, + accentButton = accentButton, modifier = modifier.let { if (iconOnly) { - it.defaultMinSize(32.dp, 32.dp) + it.defaultMinSize(32.dp, buttonMinHeight) } else { it.defaultMinSize( - minWidth = 120.dp, - minHeight = 32.dp + minHeight = buttonMinHeight ) } - }, - shape = RoundedCornerShape(4.dp), - border = BorderStroke(1.dp, buttonColor.borderBrush), - color = fillColor, - contentColor = contentColor, - outsideBorder = !accentButton, - cornerRadius = 4.dp + } ) { Row( Modifier - .clickable( - onClick = onClick, - interactionSource = interaction, - indication = null + .then( + if (onClick != null) { + Modifier.clickable( + onClick = onClick, + interactionSource = interaction, + indication = null, + enabled = !disabled + ) + } else { + Modifier + } ) .then(if (iconOnly) Modifier else Modifier.padding(horizontal = 12.dp)), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + horizontalArrangement = contentArrangement, verticalAlignment = Alignment.CenterVertically, content = content ) } } +/* +common interaction layer for button and split button. +*/ @Composable -private fun buttonColors(): ButtonColors { - val colors = FluentTheme.colors - - return remember(colors) { - ButtonColors( - default = ButtonColor( - colors.control.default, - colors.text.text.primary, - colors.borders.control - ), - hovered = ButtonColor( - colors.control.secondary, - colors.text.text.primary, - colors.borders.control - ), - pressed = ButtonColor( - colors.control.tertiary, - colors.text.text.secondary, - SolidColor(colors.stroke.control.default) - ), - disabled = ButtonColor( - colors.control.disabled, - colors.text.text.disabled, - SolidColor(colors.stroke.control.default) - ) - ) - } +private fun ButtonLayer( + shape: Shape, + buttonColors: VisualStateScheme, + interaction: MutableInteractionSource, + disabled: Boolean, + accentButton: Boolean, + displayBorder: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val buttonColor = buttonColors.schemeFor(interaction.collectVisualState(disabled)) + + val fillColor by animateColorAsState( + buttonColor.fillColor, + animationSpec = tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + ) + + val contentColor by animateColorAsState( + buttonColor.contentColor, + animationSpec = tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + ) + Layer( + modifier = modifier, + shape = shape, + color = fillColor, + contentColor = contentColor, + border = if (displayBorder) BorderStroke(buttonBorderStrokeWidth, buttonColor.borderBrush) else null, + backgroundSizing = if (!accentButton) BackgroundSizing.InnerBorderEdge else BackgroundSizing.OuterBorderEdge, + content = content + ) } -@Composable -private fun accentButtonColors(): ButtonColors { - val colors = FluentTheme.colors - return remember(colors) { - ButtonColors( - default = ButtonColor( - colors.fillAccent.default, - colors.text.onAccent.primary, - colors.borders.accentControl - ), - hovered = ButtonColor( - colors.fillAccent.secondary, - colors.text.onAccent.primary, - colors.borders.accentControl - ), - pressed = ButtonColor( - colors.fillAccent.tertiary, - colors.text.onAccent.secondary, - SolidColor(colors.stroke.control.onAccentDefault) - ), - disabled = ButtonColor( - colors.fillAccent.disabled, - colors.text.onAccent.disabled, - SolidColor(Color.Transparent) // Disabled accent button does not have border - ) +object ButtonDefaults { + + @Stable + @Composable + fun buttonColors( + default: ButtonColor = ButtonColor( + fillColor = FluentTheme.colors.control.default, + contentColor = FluentTheme.colors.text.text.primary, + borderBrush = FluentTheme.colors.borders.control + ), + hovered: ButtonColor = default.copy( + fillColor = FluentTheme.colors.control.secondary + ), + pressed: ButtonColor = ButtonColor( + fillColor = FluentTheme.colors.control.tertiary, + contentColor = FluentTheme.colors.text.text.secondary, + borderBrush = SolidColor(FluentTheme.colors.stroke.control.default) + ), + disabled: ButtonColor = pressed.copy( + fillColor = FluentTheme.colors.control.disabled, + contentColor = FluentTheme.colors.text.text.disabled, ) - } + ) = ButtonColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) + + @Stable + @Composable + fun accentButtonColors( + default: ButtonColor = ButtonColor( + fillColor = FluentTheme.colors.fillAccent.default, + contentColor = FluentTheme.colors.text.onAccent.primary, + borderBrush = FluentTheme.colors.borders.accentControl + ), + hovered: ButtonColor = default.copy( + fillColor = FluentTheme.colors.fillAccent.secondary + ), + pressed: ButtonColor = ButtonColor( + fillColor = FluentTheme.colors.fillAccent.tertiary, + contentColor = FluentTheme.colors.text.onAccent.secondary, + borderBrush = SolidColor(FluentTheme.colors.stroke.control.onAccentDefault) + ), + disabled: ButtonColor = ButtonColor( + fillColor = FluentTheme.colors.fillAccent.disabled, + contentColor = FluentTheme.colors.text.onAccent.disabled, + borderBrush = SolidColor(Color.Transparent) // Disabled accent button does not have border + ) + ) = ButtonColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) + + @Stable + @Composable + fun subtleButtonColors( + default: ButtonColor = ButtonColor( + fillColor = FluentTheme.colors.subtleFill.transparent, + contentColor = FluentTheme.colors.text.text.primary, + borderBrush = SolidColor(Color.Transparent) + ), + hovered: ButtonColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.secondary, + ), + pressed: ButtonColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.tertiary, + contentColor = FluentTheme.colors.text.text.secondary + ), + disabled: ButtonColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.disabled, + contentColor = FluentTheme.colors.text.text.disabled + ) + ) = ButtonColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled, + ) + + @Stable + @Composable + fun hyperlinkButtonColors( + default: ButtonColor = ButtonColor( + fillColor = FluentTheme.colors.subtleFill.transparent, + contentColor = FluentTheme.colors.text.accent.primary, + borderBrush = SolidColor(Color.Transparent) + ), + hovered: ButtonColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.secondary + ), + pressed: ButtonColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.tertiary, + contentColor = FluentTheme.colors.text.accent.secondary, + ), + disabled: ButtonColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.disabled, + contentColor = FluentTheme.colors.text.accent.disabled, + ) + ) = ButtonColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) } @Composable -private fun subtleButtonColors(): ButtonColors { - val colors = FluentTheme.colors - return remember(colors) { - ButtonColors( - default = ButtonColor( - colors.subtleFill.transparent, - colors.text.text.primary, - SolidColor(Color.Transparent) - ), - hovered = ButtonColor( - colors.subtleFill.secondary, - colors.text.text.primary, - SolidColor(Color.Transparent) - ), - pressed = ButtonColor( - colors.subtleFill.tertiary, - colors.text.text.secondary, - SolidColor(Color.Transparent) - ), - disabled = ButtonColor( - colors.subtleFill.disabled, - colors.text.text.disabled, - SolidColor(Color.Transparent) - ), - ) - } -} \ No newline at end of file +private fun AnimatedDropDownIcon(interaction: MutableInteractionSource) { + val isPressed by interaction.collectIsPressedAsState() + val animatedOffset = animateDpAsState( + targetValue = if (isPressed) 2.dp else 0.dp, + animationSpec = tween(FluentDuration.ShortDuration, easing = FluentEasing.FastInvokeEasing) + ) + Icon( + imageVector = Icons.Default.ChevronDown, + contentDescription = null, + modifier = Modifier.graphicsLayer { translationY = animatedOffset.value.toPx() }.size(12.dp) + ) +} + +private val buttonMinHeight = 32.dp +private val buttonShape = RoundedCornerShape(size = 4.dp) +private val buttonBorderStrokeWidth = 1.dp \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/CalendarDatePicker.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/CalendarDatePicker.kt new file mode 100644 index 00000000..e9200070 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/CalendarDatePicker.kt @@ -0,0 +1,45 @@ +package com.konyaco.fluent.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.konyaco.fluent.ExperimentalFluentApi +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.regular.CalendarLtr + +/** + * A calendar view lets a user view and interact with a calendar that they can navigate by month, year, or decade. A user can select a single date or a range of dates. It doesn't have a picker surface and the calendar is always visible. + * + * The calendar date picker is a drop down control that's optimized for picking a single date from a calendar view where contextual information like the day of the week or fullness of the calendar is important. The calendar date picker has an internal CalendarView for picking a date. + */ +@Composable +@ExperimentalFluentApi +fun CalendarDatePicker( + onChoose: (CalendarDatePickerState.Day) -> Unit, + state: CalendarDatePickerState = remember { CalendarDatePickerState() } +) { + var day by remember { mutableStateOf(null) } + FlyoutContainer(flyout = { + CalendarView( + onChoose = { + day = it + onChoose(it) + isFlyoutVisible = false + }, + state = state + ) + }, content = { + Button(onClick = { + isFlyoutVisible = true + }) { + Text( + day?.let { day -> + "${day.year}/${day.monthValue + 1}/${day.day}" + } ?: "Pick a date" + ) + Icon(Icons.Default.CalendarLtr, null) + } + }) +} diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/CalendarView.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/CalendarView.kt new file mode 100644 index 00000000..33d53295 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/CalendarView.kt @@ -0,0 +1,788 @@ +package com.konyaco.fluent.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.konyaco.fluent.ExperimentalFluentApi +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.LocalContentColor +import com.konyaco.fluent.animation.FluentDuration +import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.BackgroundSizing +import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.component.CalendarDatePickerState.ChooseType +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.filled.CaretDown +import com.konyaco.fluent.icons.filled.CaretUp +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.collectVisualState +import java.util.Calendar +import java.util.Date + + +/** + * CalendarView shows a large view for showing and selecting dates. + * DatePicker by contrast has a compact view with inline selection. + * TODO: Add animations, scroll behaviors and refactor codes. Add selection mode (single, multiple, range) + */ +@Composable +@ExperimentalFluentApi +fun CalendarView( + onChoose: (day: CalendarDatePickerState.Day) -> Unit, + state: CalendarDatePickerState = remember { CalendarDatePickerState() } +) { + // TODO: Replace by flyout when it's in CalendarDatePicker + Layer( + Modifier.width(300.dp), + color = FluentTheme.colors.background.acrylic.defaultFallback + ) { + Column(Modifier.width(300.dp)) { + // Header + Row( + Modifier.padding(4.dp).height(40.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CalendarHeader( + modifier = Modifier.weight(1f), + text = state.viewHeaderText.value, + onClick = { state.toggleChooseType() }, + disabled = state.currentChooseType.value == ChooseType.YEAR + ) + PaginationButton(up = true, onClick = { state.up() }) + PaginationButton(up = false, onClick = { state.down() }) + } + // Divider + Box( + Modifier.fillMaxWidth().height(1.dp) + .background(FluentTheme.colors.stroke.card.default) + ) + + // Content + Layer( + Modifier.fillMaxWidth().height(300.dp), + color = FluentTheme.colors.background.layer.default, + border = null + ) { + AnimatedContent( + modifier = Modifier.fillMaxSize(), + targetState = state.currentChooseType.value, + transitionSpec = { + if (targetState < initialState) { + zoomInTransition + } else { + zoomOutTransition + } + } + ) { + when (it) { + ChooseType.YEAR -> YearTable(state) + ChooseType.MONTH -> MonthTable(state) + ChooseType.DAY -> DateTable(state, onChoose = onChoose) + } + } + } + } + } +} + +private val zoomInTransition = ContentTransform( + targetContentEnter = fadeIn( + tween( + FluentDuration.MediumDuration, + FluentDuration.QuickDuration, + easing = FluentEasing.FastInvokeEasing + ), + ) + scaleIn( + tween( + FluentDuration.MediumDuration, + FluentDuration.QuickDuration, + easing = FluentEasing.FastInvokeEasing + ), + initialScale = 1.5f + ), + initialContentExit = fadeOut( + tween( + FluentDuration.ShortDuration, + easing = FluentEasing.FastInvokeEasing + ) + ) + scaleOut( + tween( + FluentDuration.MediumDuration, + easing = FluentEasing.FastInvokeEasing + ), + targetScale = 0.5f + ), +) + +// TODO: Find out the animation spec. +private val zoomOutTransition = ContentTransform( + targetContentEnter = fadeIn( + tween( + FluentDuration.MediumDuration, + FluentDuration.QuickDuration, + easing = FluentEasing.FastInvokeEasing + ), + ) + scaleIn( + tween( + FluentDuration.MediumDuration, + FluentDuration.QuickDuration, + easing = FluentEasing.FastInvokeEasing + ), + initialScale = 0.8f + ), + initialContentExit = fadeOut( + tween( + FluentDuration.ShortDuration, + easing = FluentEasing.FastInvokeEasing + ) + ) + scaleOut( + tween( + FluentDuration.MediumDuration, + easing = FluentEasing.FastInvokeEasing + ), + targetScale = 1.5f + ), +) + +@Composable +private fun YearTable(state: CalendarDatePickerState) { + // 4x4 + LazyVerticalGrid( + GridCells.Fixed(4), + modifier = Modifier.height(300.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + verticalArrangement = Arrangement.SpaceAround, + contentPadding = PaddingValues(12.dp) + + ) { + itemsIndexed(state.candidateYears.value) { i, e -> + Box(contentAlignment = Alignment.Center) { + Item( + text = e.value.toString(), + header = null, + size = ItemSize.MonthYear, + current = state.currentDay.value.year == e.value, + selected = false, + blackOut = false, + outOfRange = false, + onSelectedChange = { state.selectYear(e) } + ) + } + } + } +} + +@Composable +private fun MonthTable(state: CalendarDatePickerState) { + // 4x4 + val names = state.monthNames.value + LazyVerticalGrid( + GridCells.Fixed(4), + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.SpaceAround, + verticalArrangement = Arrangement.SpaceAround, + contentPadding = PaddingValues(12.dp) + ) { + itemsIndexed(state.candidateMonths.value) { i, e -> + Box(contentAlignment = Alignment.Center) { + val month by rememberUpdatedState(e) + val isCurrent by remember { + derivedStateOf { + state.currentDay.value.year == month.year && state.currentDay.value.monthValue == month.monthValue + } + } + val header by remember { derivedStateOf { if (month.monthValue == 0) month.year.toString() else null } } + val outOfRange by remember { derivedStateOf { month.year != state.viewMonth.value.year } } + Item( + text = names[month.monthValue], + header = header, + size = ItemSize.MonthYear, + current = isCurrent, + selected = false, + blackOut = false, + outOfRange = outOfRange, + onSelectedChange = { state.selectMonth(month) } + ) + } + } + } +} + +@Composable +private fun DateTable( + state: CalendarDatePickerState, + onChoose: (day: CalendarDatePickerState.Day) -> Unit +) { + Column(Modifier.padding(4.dp)) { + // Day of Weeks + Row( + modifier = Modifier.fillMaxWidth().height(40.dp), + horizontalArrangement = Arrangement.SpaceAround + ) { + val weekNames = state.dayOfWeekNames.value + val start = state.localeStartDayOfWeek - 1 + + for (i in weekNames.indices) { + val j = (start + i) % weekNames.size + DaysOfTheWeek(weekNames[j]) + } + } + Spacer(Modifier.height(2.dp)) + LazyVerticalGrid( + GridCells.Fixed(7), + modifier = Modifier.height(250.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + itemsIndexed(state.candidateDays.value) { i, day -> + Box(contentAlignment = Alignment.Center) { + val day by rememberUpdatedState(day) + val current by remember { derivedStateOf { day == state.currentDay.value } } + val selected by remember { derivedStateOf { day == state.selectedDay.value } } + val outOfRange by remember { derivedStateOf { day.monthValue != state.viewMonth.value.monthValue } } + val header by remember { derivedStateOf { if (day.day == 1) state.monthNames.value[day.monthValue % 12] else null } } + Item( + text = day.day.toString(), + size = ItemSize.Day, + header = header, + current = current, + selected = selected, + blackOut = false, // TODO: Support blackOut later + outOfRange = outOfRange, + onSelectedChange = { + state.selectDay(day) + onChoose(day) + } + ) + } + } + } + } +} + +private enum class ItemSize { + Day, MonthYear +} + +private val headerTextTransition = fadeIn( + tween( + durationMillis = FluentDuration.QuickDuration, + delayMillis = FluentDuration.QuickDuration, + easing = FluentEasing.FastInvokeEasing + ) +) + .togetherWith( + fadeOut( + tween( + durationMillis = FluentDuration.QuickDuration, + easing = FluentEasing.FastInvokeEasing + ) + ) + ) + +@Composable +private fun CalendarHeader( + modifier: Modifier, + text: String, + disabled: Boolean, + onClick: () -> Unit +) { + Box(modifier.height(40.dp).padding(horizontal = 4.dp), Alignment.Center) { + SubtleButton( + modifier = Modifier.height(30.dp), + iconOnly = true, + onClick = onClick, + disabled = disabled + ) { + Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { + Text(text = text, style = FluentTheme.typography.bodyStrong, textAlign = TextAlign.Start) + // FIXME: The animation is only enabled when change choose type + /*AnimatedContent( + modifier = Modifier.fillMaxWidth(), + targetState = text, + transitionSpec = { headerTextTransition }) { + Text( + text = it, + style = FluentTheme.typography.bodyStrong, + textAlign = TextAlign.Start + ) + }*/ + } + } + } +} + +@Composable +private fun PaginationButton( + up: Boolean, + onClick: () -> Unit +) { + Box(Modifier.requiredSize(40.dp), Alignment.Center) { + SubtleButton(modifier = Modifier.height(30.dp), onClick = onClick, iconOnly = true) { + if (up) Icon( + modifier = Modifier.size(12.dp), + imageVector = Icons.Filled.CaretUp, + contentDescription = "Up" + ) + else Icon( + modifier = Modifier.size(12.dp), + imageVector = Icons.Filled.CaretDown, + contentDescription = "Down" + ) + } + } +} + +@Composable +private fun DaysOfTheWeek(text: String) { + Box(Modifier.size(38.dp), contentAlignment = Alignment.Center) { + Text(text = text, style = FluentTheme.typography.bodyStrong) + } +} + +private val activeColor + @Composable + get() = PentaVisualScheme( + default = FluentTheme.colors.fillAccent.default, + hovered = FluentTheme.colors.fillAccent.secondary, + pressed = FluentTheme.colors.fillAccent.tertiary, + disabled = FluentTheme.colors.fillAccent.disabled, + focused = FluentTheme.colors.fillAccent.secondary + ) + +private val inactiveColor + @Composable + get() = PentaVisualScheme( + default = FluentTheme.colors.subtleFill.transparent, + hovered = FluentTheme.colors.subtleFill.secondary, + pressed = FluentTheme.colors.subtleFill.tertiary, + disabled = FluentTheme.colors.subtleFill.disabled, + focused = FluentTheme.colors.subtleFill.transparent + ) + +@Composable +private fun Item( + text: String, + header: String?, + size: ItemSize, + current: Boolean, + selected: Boolean, + blackOut: Boolean, + outOfRange: Boolean, + onSelectedChange: (Boolean) -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + val visualState = interactionSource.collectVisualState(disabled = false) + + val fillColor = if (current && !selected) { + activeColor.schemeFor(visualState) + } else { + inactiveColor.schemeFor(visualState) + } + + val contentColor = if (current) { + FluentTheme.colors.text.onAccent.primary + } else { + if (selected) { + FluentTheme.colors.text.accent.primary + } else if (outOfRange) { + FluentTheme.colors.text.text.tertiary // Should be secondary in Figma, but it's more clear to use tertiary + } else { + FluentTheme.colors.text.text.primary + } + } + + val strokeColor = if (current) { + if (selected) FluentTheme.colors.fillAccent.default + else FluentTheme.colors.fillAccent.default + } else { + if (selected) FluentTheme.colors.fillAccent.default + else Color.Transparent + } + + + Layer( + modifier = Modifier.size(if (size == ItemSize.MonthYear) 56.dp else 40.dp), + backgroundSizing = BackgroundSizing.InnerBorderEdge, + shape = CircleShape, + color = fillColor, + border = BorderStroke(1.dp, strokeColor), + contentColor = contentColor, + ) { + Box( + modifier = Modifier.clickable( + interactionSource = interactionSource, + indication = null + ) { onSelectedChange(!selected) }, + contentAlignment = Alignment.Center + ) { + // Selected background + if (current && selected) { + val fillColor = activeColor.schemeFor(visualState) + Box( + Modifier.fillMaxSize().padding(2.dp) + .background(fillColor, CircleShape) + ) + } + Text(text = text) + if (header != null) { + Text( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 2.dp), + text = header, + style = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 8.sp, + lineHeight = 12.sp, + color = LocalContentColor.current + ) + ) + } + } + } +} + +class CalendarDatePickerState { + private val locale = java.util.Locale.getDefault() + private val calendar = Calendar.getInstance(locale) + + val currentChooseType = mutableStateOf(ChooseType.DAY) + val viewHeaderText = mutableStateOf("") + + val dayOfWeekNames = mutableStateOf(getDowNames()) + val monthNames = mutableStateOf(getMonthNames()) + + enum class ChooseType { + YEAR, MONTH, DAY + } + + data class Year(val value: Int) + data class Month(val year: Int, val monthValue: Int) + data class Day(val year: Int, val monthValue: Int, val day: Int) + + // val currentYear: MutableState +// val currentMonth: MutableState + val currentDay: MutableState + + // val viewYear: MutableState + val viewMonth: MutableState +// val viewDay: MutableState + + val selectedDay: MutableState + + // Should be 7 * 6 = 42 cells + val candidateYears = mutableStateOf>(emptyList()) + val candidateMonths = mutableStateOf>(emptyList()) + val candidateDays = mutableStateOf>(emptyList()) + + // TODO: For localization, e.g. In China the start of week is Monday + // FIXME: `firstDayOfWeek` should return 2(Monday), but returns 1(Sunday) in Java 17 + val localeStartDayOfWeek = calendar.firstDayOfWeek +// val localeStartDayOfWeek = 2 + + private fun getDowNames(): List { + val names = calendar.getDisplayNames( + Calendar.DAY_OF_WEEK, + Calendar.NARROW_STANDALONE, + locale + ) ?: calendar.getDisplayNames( + Calendar.DAY_OF_WEEK, + Calendar.SHORT_STANDALONE, + locale + ) + val result = MutableList(7) { "" } + + for (entry in names.entries) { + // minus 1 because dof start from 1 + result[entry.value - 1] = entry.key + } + return result + } + + private fun getMonthNames(): List { + val names = calendar.getDisplayNames(Calendar.MONTH, Calendar.SHORT_STANDALONE, locale) + val result = MutableList(12) { "" } + for (entry in names.entries) { + result[entry.value] = entry.key + } + return result + } + + init { + val calendar = Calendar.getInstance() + calendar.time = Date() + val year = calendar.get(Calendar.YEAR) + val monthValue = calendar.get(Calendar.MONTH) + val day = calendar.get(Calendar.DAY_OF_MONTH) +// currentYear = mutableStateOf(Year(year)) +// viewYear = mutableStateOf(Year(year)) +// selectedYear = mutableStateOf(Year(year)) +// currentMonth = mutableStateOf(Month(year, monthValue)) + viewMonth = mutableStateOf(Month(year, monthValue)) +// selectedMonth = mutableStateOf(Month(year, monthValue)) + currentDay = mutableStateOf(Day(year, monthValue, day)) +// viewDay = mutableStateOf(Day(year, monthValue, day)) + selectedDay = mutableStateOf(Day(year, monthValue, day)) + calculateCandidateYears(year) + calculateCandidateMonths(year) + calculateCandidateDays(year, monthValue) + computeHeaderText() + } + + private fun calculateCandidateYears(currentYear: Int) { + val startYear = currentYear / 10 * 10 // 2024 -> 2020 + val endYear = startYear + 16 + val candidateYears = (startYear until endYear).map { + Year(it) + } + this.candidateYears.value = candidateYears + } + + private fun calculateCandidateMonths(year: Int) { + val candidateMonths = (0..15).map { + Month(if (it >= 12) year + 1 else year, it % 12) + } + this.candidateMonths.value = candidateMonths + } + + private fun calculateCandidateDays(year: Int, monthValue: Int) { + val calendar = Calendar.getInstance() + calendar.set(year, monthValue, 1) + val startDayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) // Start at Sunday(1) + + // Start day at the first `localeStartDayOfWeek(e.g. Sunday)` before this month + // If localeStartDOW = Sunday(1): + // Wednesday(4) - Sunday(1) = 3 + // If localeStartDOW = Monday(2): + // Sunday(1) - Monday(2) = -1 -> 6 (case 1) + // Wednesday(4) - Monday(2) = 2 + // If this month (5/1) is Wednesday(4th), the offset should be 3, the start day should be 5/1 - 3days = 4/28 + // If this month (9/1) is Sunday(1th), the offset should be 0, the start day should be 7/1 - 0days = 7/1 + + var startDayOffset = startDayOfWeek - localeStartDayOfWeek + if (startDayOffset < 0) { // for case 1 + startDayOffset += 7 + } + + val startDayOfYear = calendar.get(Calendar.DAY_OF_YEAR) - startDayOffset + + // e.g from 4-29 to 6-9 + val daysToDisplay = 7 * 6 + val candidateDays = (startDayOfYear until startDayOfYear + daysToDisplay).map { dayOfYear -> + calendar.set(Calendar.DAY_OF_YEAR, dayOfYear) + val monthValue = calendar.get(Calendar.MONTH) + val day = calendar.get(Calendar.DAY_OF_MONTH) + Day(year, monthValue, day) + } + this.candidateDays.value = candidateDays + } + + internal fun toggleChooseType() { + when (currentChooseType.value) { + ChooseType.YEAR -> { + // Do nothing + } + + ChooseType.MONTH -> { + // Choose year + currentChooseType.value = ChooseType.YEAR + } + + ChooseType.DAY -> { + // Choose month + currentChooseType.value = ChooseType.MONTH + } + } + computeHeaderText() + calculateCandidates() + } + + internal fun selectYear(year: Year) { +// selectedYear.value = year + viewMonth.value = Month(year.value, viewMonth.value.monthValue) + currentChooseType.value = ChooseType.MONTH + + calculateCandidateMonths(year.value) + computeHeaderText() + } + + internal fun selectMonth(month: Month) { +// selectedMonth.value = month + viewMonth.value = Month(month.year, month.monthValue) + currentChooseType.value = ChooseType.DAY + + calculateCandidateDays(month.year, month.monthValue) + computeHeaderText() + } + + internal fun selectDay(day: Day) { +// viewMonth.value = Month(day.year, day.monthValue) + selectedDay.value = day + computeHeaderText() + } + + internal fun down() { + when (currentChooseType.value) { + ChooseType.YEAR -> { + nextYears() + } + + ChooseType.MONTH -> { + nextMonths() + } + + ChooseType.DAY -> { + nextMonth() + } + } + } + + internal fun up() { + when (currentChooseType.value) { + ChooseType.YEAR -> { + previousYears() + } + + ChooseType.MONTH -> { + previousMonths() + } + + ChooseType.DAY -> { + previousMonth() + } + } + } + + private fun previousYears() { + viewMonth.value = Month(viewMonth.value.year - 10, viewMonth.value.monthValue) + calculateCandidateYears(viewMonth.value.year) + computeHeaderText() + } + + private fun nextYears() { + viewMonth.value = Month(viewMonth.value.year + 10, viewMonth.value.monthValue) + calculateCandidateYears(viewMonth.value.year) + computeHeaderText() + } + + private fun previousMonths() { + val previousYear = viewMonth.value.year - 1 + viewMonth.value = Month(previousYear, viewMonth.value.monthValue) + calculateCandidateMonths(previousYear) + computeHeaderText() + } + + private fun nextMonths() { + val nextYear = viewMonth.value.year + 1 + viewMonth.value = Month(nextYear, viewMonth.value.monthValue) + calculateCandidateMonths(nextYear) + computeHeaderText() + } + + private fun previousMonth() { + val current = viewMonth.value + var year = current.year + var month = current.monthValue + if (month == 0) { + month = 11 + year -= 1 + } else { + month -= 1 + } + viewMonth.value = Month(year, month) + calculateCandidates() + computeHeaderText() + } + + private fun nextMonth() { + val current = viewMonth.value + var year = current.year + var month = current.monthValue + if (month == 11) { + month = 0 + year += 1 + } else { + month += 1 + } + viewMonth.value = Month(year, month) + calculateCandidates() + computeHeaderText() + } + + private fun calculateCandidates() { + val curr = viewMonth.value + calculateCandidateYears(curr.year) + calculateCandidateMonths(curr.year) + calculateCandidateDays(curr.year, curr.monthValue) + } + + + private fun computeHeaderText() { + when (currentChooseType.value) { + ChooseType.YEAR -> { + // 2020-2029 + val startYear = viewMonth.value.year / 10 * 10 // 2024 -> 2020 + val endYear = startYear + 9 + viewHeaderText.value = "$startYear-$endYear" + } + + ChooseType.MONTH -> { + // 2024 + viewHeaderText.value = viewMonth.value.year.toString() + } + + ChooseType.DAY -> { + val displayNames = + calendar.getDisplayNames(Calendar.MONTH, Calendar.SHORT_STANDALONE, locale) + val curr = viewMonth.value + val monthValue = curr.monthValue + val year = curr.year + val name = displayNames.firstNotNullOf { (k, v) -> + if (v == monthValue) k + else null + } + // TODO: Should be "May 2024" / "2024年 5月" + viewHeaderText.value = "$name $year" + } + } + } +} \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/CheckBox.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/CheckBox.kt index ae7cc9ec..b57a5f4d 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/CheckBox.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/CheckBox.kt @@ -2,14 +2,23 @@ package com.konyaco.fluent.component import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeOut import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -20,9 +29,13 @@ import androidx.compose.ui.unit.dp import com.konyaco.fluent.FluentTheme import com.konyaco.fluent.animation.FluentDuration import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.BackgroundSizing import com.konyaco.fluent.background.Layer import com.konyaco.fluent.icons.Icons import com.konyaco.fluent.icons.regular.Checkmark +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.VisualStateScheme +import com.konyaco.fluent.scheme.collectVisualState @Composable fun CheckBox( @@ -30,13 +43,16 @@ fun CheckBox( label: String? = null, modifier: Modifier = Modifier, enabled: Boolean = true, + colors: VisualStateScheme = if(checked) { + CheckBoxDefaults.selectedCheckBoxColors() + } else { + CheckBoxDefaults.defaultCheckBoxColors() + }, onCheckStateChange: (checked: Boolean) -> Unit ) { // TODO: Animation, TripleStateCheckbox val interactionSource = remember { MutableInteractionSource() } - val hovered by interactionSource.collectIsHoveredAsState() - val pressed by interactionSource.collectIsPressedAsState() - + val color = colors.schemeFor(interactionSource.collectVisualState(!enabled)) Row( modifier = modifier.then( if (label != null) Modifier.defaultMinSize(minWidth = 120.dp) @@ -48,49 +64,34 @@ fun CheckBox( ) { onCheckStateChange(!checked) }, verticalAlignment = Alignment.CenterVertically ) { - val colors = FluentTheme.colors - val fillColor by animateColorAsState( - if (checked) when { - !enabled -> colors.fillAccent.disabled - pressed -> colors.fillAccent.tertiary - hovered -> colors.fillAccent.secondary - else -> colors.fillAccent.default - } else when { - !enabled -> colors.controlAlt.disabled - pressed -> colors.controlAlt.quaternary - hovered -> colors.controlAlt.tertiary - else -> colors.controlAlt.secondary - }, - tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + val fillColor by animateColorAsState(color.fillColor, + tween(FluentDuration.QuickDuration, easing = FluentEasing.FadeInFadeOutEasing) ) Layer( modifier = Modifier.size(20.dp), shape = RoundedCornerShape(4.dp), - border = BorderStroke( - 1.dp, if (checked) when { - !enabled -> colors.fillAccent.disabled - else -> Color.Transparent - } else when { - !enabled -> colors.controlStrong.disabled - else -> colors.controlStrong.default - } - ), color = fillColor, - contentColor = when { - !enabled -> colors.text.onAccent.disabled - pressed -> colors.text.onAccent.secondary - else -> colors.text.onAccent.primary - }, - outsideBorder = !checked, - cornerRadius = 4.dp + contentColor = color.contentColor, + border = BorderStroke(1.dp, color.borderColor), + backgroundSizing = if (!checked) BackgroundSizing.InnerBorderEdge else BackgroundSizing.OuterBorderEdge ) { - // TODO: Animation - Box(contentAlignment = Alignment.Center) { - if (checked) Icon( - modifier = Modifier.size(16.dp), - imageVector = Icons.Default.Checkmark, - contentDescription = null - ) + Box(contentAlignment = Alignment.CenterStart) { + androidx.compose.animation.AnimatedVisibility( + visible = checked, + enter = expandHorizontally( + expandFrom = Alignment.Start + ), exit = fadeOut( + tween(durationMillis = FluentDuration.QuickDuration, easing = FluentEasing.FadeInFadeOutEasing) + ) + ) { + Box(Modifier.fillMaxSize(), Alignment.Center) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.Default.Checkmark, + contentDescription = null + ) + } + } } } @@ -99,8 +100,79 @@ fun CheckBox( Text( modifier = Modifier.offset(y = (-1).dp), text = it, - style = FluentTheme.typography.body.copy(color = colors.text.text.primary) + style = FluentTheme.typography.body.copy(color = color.labelTextColor) ) } } +} + +typealias CheckBoxColorScheme = PentaVisualScheme + +@Immutable +data class CheckBoxColor( + val fillColor: Color, + val contentColor: Color, + val borderColor: Color, + val labelTextColor: Color +) + +object CheckBoxDefaults { + + @Stable + @Composable + fun defaultCheckBoxColors( + default: CheckBoxColor = CheckBoxColor( + fillColor = FluentTheme.colors.controlAlt.secondary, + contentColor = FluentTheme.colors.text.onAccent.primary, + borderColor = FluentTheme.colors.controlStrong.default, + labelTextColor = FluentTheme.colors.text.text.primary + ), + hovered: CheckBoxColor = default.copy( + fillColor = FluentTheme.colors.controlAlt.tertiary, + ), + pressed: CheckBoxColor = default.copy( + fillColor = FluentTheme.colors.controlAlt.quaternary, + contentColor = FluentTheme.colors.text.onAccent.secondary + ), + disabled: CheckBoxColor = CheckBoxColor( + fillColor = FluentTheme.colors.controlAlt.disabled, + contentColor = FluentTheme.colors.text.onAccent.disabled, + borderColor = FluentTheme.colors.controlStrong.disabled, + labelTextColor = FluentTheme.colors.text.text.primary + ) + ) = CheckBoxColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) + + @Stable + @Composable + fun selectedCheckBoxColors( + default: CheckBoxColor = CheckBoxColor( + fillColor = FluentTheme.colors.fillAccent.default, + contentColor = FluentTheme.colors.text.onAccent.primary, + borderColor = Color.Transparent, + labelTextColor = FluentTheme.colors.text.text.primary + ), + hovered: CheckBoxColor = default.copy( + fillColor = FluentTheme.colors.fillAccent.secondary, + ), + pressed: CheckBoxColor = default.copy( + fillColor = FluentTheme.colors.fillAccent.tertiary, + contentColor = FluentTheme.colors.text.onAccent.secondary + ), + disabled: CheckBoxColor = CheckBoxColor( + fillColor = FluentTheme.colors.fillAccent.disabled, + contentColor = FluentTheme.colors.text.onAccent.disabled, + borderColor = FluentTheme.colors.fillAccent.disabled, + labelTextColor = FluentTheme.colors.text.text.primary + ) + ) = CheckBoxColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) } \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/ColorPicker.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/ColorPicker.kt new file mode 100644 index 00000000..c9d39f37 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/ColorPicker.kt @@ -0,0 +1,876 @@ +package com.konyaco.fluent.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PointMode +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.LocalContentColor +import com.konyaco.fluent.background.BackgroundSizing +import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.regular.ChevronDown +import com.konyaco.fluent.icons.regular.ChevronUp +import kotlinx.coroutines.flow.collectLatest +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +@Composable +fun ColorPicker( + color: Color = Color.White, + onSelectedColorChanged: (color: Color) -> Unit, + modifier: Modifier = Modifier, + dot: @Composable () -> Unit = { ColorPickerDefaults.dot() }, + label: @Composable (color: Color) -> Unit = { ColorPickerDefaults.label(it) }, + colorSpectrum: ColorSpectrum = ColorSpectrum.Square, + alphaEnabled: Boolean = false, + moreButtonVisible: Boolean = false, +) { + val spectrumColor = remember { mutableStateOf(color) } + var value by remember { mutableStateOf(color.hsv().third) } + var alpha by remember { mutableStateOf(color.alpha) } + var expanded by remember { mutableStateOf(false) } + Column(modifier = modifier.width(312.dp)) { + Row { + colorSpectrum.content( + label = label, + dot = { + CompositionLocalProvider( + LocalContentColor provides if (spectrumColor.value.luminance() > 0.5f) { + Color.Black + } else { + Color.White + }, + content = dot + ) + }, + color = spectrumColor.value, + onSelectedColorChanged = { + spectrumColor.value = it + onSelectedColorChanged(it.copy(alpha)) + }, + modifier = Modifier.size(256.dp) + ) + Layer( + modifier = Modifier + .weight(1f) + .wrapContentWidth(Alignment.End) + .width(44.dp) + .height(256.dp) + .alphaBackground(RoundedCornerShape(4.dp), alphaEnabled), + backgroundSizing = BackgroundSizing.OuterBorderEdge, + color = color + ) {} + } + val (hug, saturation, _) = spectrumColor.value.hsv() + Slider( + value = value, + onValueChange = { + onSelectedColorChanged( + Color.hsv( + hug, + saturation, + it.coerceIn(0f, 1f) + ) + ) + value = it + }, + rail = { + Box( + modifier = Modifier + .fillMaxWidth() + .requiredHeight(12.dp) + .background( + brush = Brush.horizontalGradient( + listOf( + Color.Black, + Color.hsv(hug, saturation, 1f) + ) + ), + shape = CircleShape + ) + ) + }, + track = { _, _ -> }, + thumb = { fraction, maxWidth, dragging -> + SliderDefaults.Thumb( + fraction = fraction, + maxWidth = maxWidth, + dragging = dragging, + color = FluentTheme.colors.text.text.primary + ) + }, + modifier = Modifier.padding(top = 21.dp).width(312.dp).height(32.dp) + ) + if (alphaEnabled) { + Slider( + value = alpha, + onValueChange = { + onSelectedColorChanged(color.copy(alpha = it)) + alpha = it + }, + rail = { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(12.dp) + .alphaBackground(CircleShape) + .background( + Brush.horizontalGradient( + 0f to color.copy(0f), + 1f to color.copy(1f) + ) + ) + ) + }, + track = { _, _ -> }, + thumb = { fraction, maxWidth, dragging -> + SliderDefaults.Thumb( + fraction = fraction, + maxWidth = maxWidth, + dragging = dragging, + color = FluentTheme.colors.text.text.primary + ) + }, + modifier = Modifier.width(312.dp).height(32.dp) + ) + } + Spacer(modifier = Modifier.height(20.dp)) + if (moreButtonVisible) { + val defaultColor = ButtonColor( + fillColor = FluentTheme.colors.subtleFill.transparent, + contentColor = FluentTheme.colors.text.text.primary, + borderBrush = SolidColor(Color.Transparent) + ) + SubtleButton( + onClick = { expanded = !expanded }, + content = { + val text = if (!expanded) "More" else "Less" + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = text) + Icon( + imageVector = if (!expanded) Icons.Default.ChevronDown else Icons.Default.ChevronUp, + contentDescription = text, + modifier = Modifier.size(12.dp) + ) + } + }, + buttonColors = ButtonDefaults.subtleButtonColors( + default = defaultColor, + hovered = defaultColor.copy(contentColor = FluentTheme.colors.text.text.secondary), + pressed = defaultColor.copy(contentColor = FluentTheme.colors.text.text.tertiary) + ), + modifier = Modifier.align(Alignment.End) + ) + } + if (moreButtonVisible && !expanded) return@Column + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + var isRGBTextField by remember { mutableStateOf(true) } + Row(horizontalArrangement = Arrangement.SpaceAround) { + BasicFlyoutContainer( + flyout = { + MenuFlyout( + onDismissRequest = { isFlyoutVisible = false }, + visible = isFlyoutVisible, + modifier = Modifier.width(120.dp), + placement = FlyoutPlacement.Bottom, + adaptivePlacement = true + ) { + MenuFlyoutItem( + selected = isRGBTextField, + onSelectedChanged = { + isRGBTextField = true + isFlyoutVisible = false + }, + text = { Text("RGB") }, + modifier = Modifier.defaultMinSize(120.dp) + ) + MenuFlyoutItem( + selected = !isRGBTextField, + onSelectedChanged = { + isRGBTextField = false + isFlyoutVisible = false + }, + text = { Text("HSV") }, + modifier = Modifier.defaultMinSize(120.dp) + ) + } + }, + modifier = Modifier.width(120.dp) + ) { + DropDownButton( + onClick = { isFlyoutVisible = !isFlyoutVisible }, + content = { + Text( + text = if (isRGBTextField) "RGB" else "HSV", + modifier = Modifier.weight(1f) + ) + }, + modifier = Modifier.width(120.dp) + ) + } + Spacer(modifier = Modifier.weight(1f)) + HexColorTextField( + color = color, + onValueChanged = { + onSelectedColorChanged(it) + spectrumColor.value = it.copy(1f) + }, + alphaEnabled = alphaEnabled, + ) + } + if (isRGBTextField) { + ColorTextField( + value = (color.red * 255).toInt(), + onValueChanged = { + onSelectedColorChanged(color.copy(red = (it.toFloat() / 255f))) + spectrumColor.value = color + }, + label = "Red" + ) + ColorTextField( + value = (color.green * 255).toInt(), + onValueChanged = { + onSelectedColorChanged(color.copy(green = (it.toFloat() / 255f))) + spectrumColor.value = color + }, + label = "Blue" + ) + ColorTextField( + value = (color.blue * 255).toInt(), + onValueChanged = { + onSelectedColorChanged(color.copy(blue = (it.toFloat() / 255f))) + spectrumColor.value = color + }, + label = "Green" + ) + } else { + ColorTextField( + value = hug.toInt(), + onValueChanged = { + val newColor = Color.hsv(it.toFloat(), saturation, value) + spectrumColor.value = newColor + onSelectedColorChanged(newColor) + }, + range = 0..360, + label = "Hug" + ) + ColorTextField( + value = (saturation * 100).toInt(), + onValueChanged = { + val newColor = Color.hsv(hug, it.toFloat() / 100f, value) + spectrumColor.value = newColor + onSelectedColorChanged(newColor) + }, + range = 0..100, + label = "Saturation" + ) + ColorTextField( + value = (value * 100).toInt(), + onValueChanged = { + value = it.toFloat() / 100f + val newColor = Color.hsv(hug, saturation, value) + onSelectedColorChanged(newColor) + }, + range = 0..100, + label = "Value" + ) + } + + if (alphaEnabled) { + ColorTextField( + value = (alpha * 100).toInt(), + onValueChanged = { + alpha = it.toFloat() / 100f + val newColor = color.copy(alpha) + onSelectedColorChanged(newColor) + }, + range = 0..100, + label = "Opacity", + suffix = "%" + ) + } + } + } +} + +@Composable +private fun ColorTextField( + value: Int, + onValueChanged: (Int) -> Unit, + label: String, + suffix: String = "", + range: IntRange = 0..255, + parse: (Int) -> String = { it.toString() }, + parseBack: (String) -> Int? = { it.toInt() } +) { + //TODO TextField clean button + var colorTextValue by remember { + mutableStateOf(TextFieldValue(parse(value) + suffix)) + } + LaunchedEffect(value) { + colorTextValue = colorTextValue.copy(parse(value) + suffix) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextField( + value = colorTextValue, + onValueChange = { + val isNewValue = colorTextValue.text != it.text + colorTextValue = it + if (isNewValue) { + val newValue = parseBack(it.text.removeSuffix(suffix)) ?: return@TextField + val inRange = newValue in range + if (newValue != value && inRange) { + onValueChanged(newValue) + } + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(120.dp).fillMaxWidth() + ) + Text(label, color = FluentTheme.colors.text.text.secondary) + } +} + +@OptIn(ExperimentalStdlibApi::class) +@Composable +fun HexColorTextField( + color: Color, + onValueChanged: (color: Color) -> Unit, + alphaEnabled: Boolean, + modifier: Modifier = Modifier +) { + //TODO TextField clean button + val hexFormat = remember { + HexFormat { + upperCase = true + number.removeLeadingZeros = false + } + } + val textFieldValue = remember { + mutableStateOf(TextFieldValue("#")) + } + + val isTextFieldInput = remember { mutableStateOf(false) } + LaunchedEffect(color) { + if (color.toArgb() != textFieldValue.value.text.removePrefix("#").toIntOrNull(16)) { + val hexString = color.value.toHexString(hexFormat) + textFieldValue.value = textFieldValue.value.copy( + text = "#" + hexString.substring( + minOf( + if (!alphaEnabled) 2 else 0, + color.value.toHexString(hexFormat).lastIndex + ), + minOf(8, color.value.toHexString(hexFormat).length) + ) + ) + } + } + TextField( + value = textFieldValue.value, + onValueChange = { + isTextFieldInput.value = true + val updateColor = textFieldValue.value.text != it.text + textFieldValue.value = it + if (updateColor) { + val newValueText = it.text.removePrefix("#") + val count = 8 - newValueText.length + val formatNewValueText = if (count > 0) { + "FF00000000".substring(0, count) + newValueText + } else { + newValueText + } + val newValue = formatNewValueText.toLongOrNull(16) + if (newValue != null && newValue in 0L..0xFFFFFFFFL) { + onValueChanged(Color(newValue)) + } + } + isTextFieldInput.value = false + }, + modifier = modifier + ) +} + +private fun Modifier.alphaBackground(shape: Shape = RectangleShape, enabled: Boolean = true) = + clip(shape).drawWithCache { + if (!enabled) return@drawWithCache onDrawBehind { } + val strokeSize = 4.dp.toPx() + val pointCenter = strokeSize / 2 + val count = (size.width / strokeSize).roundToInt() + val firstLineCount = (count / 2f).toInt() + val secondLineCount = ((count + 1) / 2f).toInt() + val lineCount = (size.height / strokeSize).roundToInt() + val itemCount = (lineCount * (firstLineCount + secondLineCount) / 2f + 0.5f).toInt() + val points = List(itemCount) { + val index = it.mod(firstLineCount + secondLineCount) + val lineIndex = it / (firstLineCount + secondLineCount) + when (index) { + in 0 until firstLineCount -> Offset( + x = pointCenter + (index * 2 + 1) * strokeSize, + y = pointCenter + 2 * lineIndex * strokeSize + ) + + else -> Offset( + x = pointCenter + (index - firstLineCount) * 2 * strokeSize, + y = pointCenter + (2 * lineIndex + 1) * strokeSize + ) + } + } + onDrawBehind { + drawPoints( + points = points, + color = Color.Gray.copy(0.2f), + strokeWidth = strokeSize, + cap = StrokeCap.Square, + pointMode = PointMode.Points + ) + } + } + + +object ColorPickerDefaults { + + @Composable + fun dot() { + Spacer( + modifier = Modifier.size(16.dp) + .border(2.dp, color = LocalContentColor.current, shape = CircleShape) + ) + } + + @Composable + fun label(color: Color) { + //TODO Tooltip label + } +} + +@Composable +fun SquareColorSpectrum( + color: Color, + onSelectedColorChanged: (color: Color) -> Unit, + modifier: Modifier = Modifier, + dot: @Composable () -> Unit = { ColorPickerDefaults.dot() }, + label: @Composable (color: Color) -> Unit = { ColorPickerDefaults.label(it) } +) { + ColorSpectrum.Square.content( + modifier = modifier, + dot = dot, + label = label, + color = color, + onSelectedColorChanged = onSelectedColorChanged + ) +} + +@Composable +fun RoundColorSpectrum( + color: Color, + onSelectedColorChanged: (color: Color) -> Unit, + modifier: Modifier = Modifier, + dot: @Composable () -> Unit = { ColorPickerDefaults.dot() }, + label: @Composable (color: Color) -> Unit = { ColorPickerDefaults.label(it) } +) { + ColorSpectrum.Round.content( + modifier = modifier, + dot = dot, + label = label, + color = color, + onSelectedColorChanged = onSelectedColorChanged + ) +} + +sealed class ColorSpectrum { + @Composable + internal abstract fun content( + modifier: Modifier, + dot: @Composable () -> Unit, + label: @Composable (color: Color) -> Unit, + color: Color, + onSelectedColorChanged: (color: Color) -> Unit + ) + + companion object { + val Default: ColorSpectrum get() = Square + } + + data object Round : ColorSpectrum() { + + @Composable + override fun content( + modifier: Modifier, + dot: @Composable () -> Unit, + label: @Composable (color: Color) -> Unit, + color: Color, + onSelectedColorChanged: (color: Color) -> Unit + ) { + + val colorPanelRect = remember { + mutableStateOf(RoundRect.Zero) + } + + Layer( + modifier = modifier, + shape = CircleShape, + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) { + val interactionSource = remember { MutableInteractionSource() } + LaunchedEffect(interactionSource) { + interactionSource.interactions + .collectLatest { + if (it is PressInteraction.Release) { + val position = it.press.pressPosition + onSelectedColorChanged( + getColorFromPosition( + colorPanelRect.value, + position + ) ?: return@collectLatest + ) + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .onGloballyPositioned { + val rect = it.boundsInParent() + colorPanelRect.value = RoundRect( + rect = rect, + cornerRadius = CornerRadius(rect.width, rect.height) + ) + } + .clickable( + interactionSource = interactionSource, + onClick = {}, + indication = null + ) + .pointerInput(Unit) { + detectDragGestures { change, _ -> + onSelectedColorChanged( + getColorFromPosition( + colorPanelRect.value, + change.position, + false + ) ?: return@detectDragGestures + ) + } + } + .background( + Brush.sweepGradient( + colors = listOf( + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Magenta, + Color.Red + ) + ), CircleShape + ) + .background( + Brush.radialGradient( + colors = listOf(Color.White, Color.Transparent) + ) + ) + ) + + if (color != Color.Unspecified) { + + val colorState = rememberUpdatedState(color) + val dotSize = remember { mutableStateOf(IntSize.Zero) } + val offset = remember { + derivedStateOf { + val (offsetX, offsetY) = getPositionFromColor( + colorState.value, + colorPanelRect.value + ) + IntOffset(offsetX.toInt(), offsetY.toInt()) - IntOffset( + dotSize.value.width / 2, + dotSize.value.height / 2 + ) + } + } + + Box { + Box( + modifier = Modifier + .offset { offset.value } + .onSizeChanged { + dotSize.value = it + } + ) { + dot() + } + } + + } + } + } + + private fun getColorFromPosition( + rect: RoundRect, + position: Offset, + excludeRadius: Boolean = true + ): Color? { + if (!excludeRadius || rect.contains(position)) { + val center = rect.center + val offsetX = position.x - center.x + val offsetY = position.y - center.y + // calculates the angle in degrees + val angle = (atan2(x = offsetX, y = offsetY) / (2 * PI) + 1f) * 360 + val distance = sqrt(offsetX * offsetX + offsetY * offsetY) + val radius = rect.width / 2 + val result = Color.hsv( + hue = angle.toFloat().mod(360f), + saturation = (distance / radius).coerceAtMost(1f), + value = 1f + ) + return result + } + return null + } + + private fun getPositionFromColor(color: Color, rect: RoundRect): Offset { + if (color == Color.Unspecified) return Offset.Zero + val radius = rect.width / 2 + val (hue, saturation, _) = color.hsv() + val angle = (hue / 360) * 2 * PI + val offsetX = cos(angle) * saturation * radius + radius + val offsetY = sin(angle) * saturation * radius + radius + return Offset(offsetX.toFloat(), offsetY.toFloat()) + } + + } + + data object Square : ColorSpectrum() { + + @Composable + override fun content( + modifier: Modifier, + dot: @Composable () -> Unit, + label: @Composable (color: Color) -> Unit, + color: Color, + onSelectedColorChanged: (color: Color) -> Unit + ) { + + Layer( + modifier = modifier, + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) { + val latestPressPosition = remember { mutableStateOf(null) } + val colorPanelRect = remember { mutableStateOf(Rect.Zero) } + val interactionSource = remember { MutableInteractionSource() } + LaunchedEffect(interactionSource) { + interactionSource.interactions.collectLatest { + if (it is PressInteraction.Release) { + latestPressPosition.value = it.press.pressPosition + onSelectedColorChanged( + getColorFromPosition( + colorPanelRect.value, + it.press.pressPosition + ) ?: return@collectLatest + ) + } + } + } + Spacer( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .onGloballyPositioned { + colorPanelRect.value = it.boundsInParent() + } + .background( + Brush.horizontalGradient( + colors = listOf( + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Magenta, + Color.Red + ) + ) + ) + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.White) + ) + ) + .clickable( + onClick = {}, + interactionSource = interactionSource, + indication = null + ) + .pointerInput(Unit) { + detectDragGestures { change, _ -> + latestPressPosition.value = change.position + onSelectedColorChanged( + getColorFromPosition( + colorPanelRect.value, + change.position, + false + ) ?: return@detectDragGestures + ) + } + } + ) + if (color != Color.Unspecified) { + val colorState = rememberUpdatedState(color) + val dotSize = remember { mutableStateOf(IntSize.Zero) } + val offset = remember { + derivedStateOf { + val currentLatestPressPosition = latestPressPosition.value + val (offsetX, offsetY) = when { + (colorState.value == Color.White || colorState.value.red == 1f) && currentLatestPressPosition != null -> { + Offset( + x = currentLatestPressPosition.x.coerceIn( + 0f, + colorPanelRect.value.width + ), + y = currentLatestPressPosition.y.coerceIn( + 0f, + colorPanelRect.value.height + ) + ) + } + + else -> { + getPositionFromColor( + colorState.value, + colorPanelRect.value + ) + } + } + IntOffset(offsetX.toInt(), offsetY.toInt()) - IntOffset( + dotSize.value.width / 2, + dotSize.value.height / 2 + ) + } + } + + Box { + Box( + modifier = Modifier + .offset { offset.value } + .wrapContentSize() + .onSizeChanged { + dotSize.value = it + } + ) { + dot() + } + } + } + } + } + + private fun getColorFromPosition( + rect: Rect, + position: Offset, + excludeRadius: Boolean = true + ): Color? { + if (!excludeRadius || rect.contains(position)) { + + val result = Color.hsv( + hue = (position.x / rect.width).coerceIn(0f, 1f) * 360, + saturation = (1 - position.y / rect.height).coerceIn(0f, 1f), + value = 1f + ) + return result + } + return null + } + + private fun getPositionFromColor(color: Color, rect: Rect): Offset { + if (color == Color.Unspecified) return Offset.Zero + val (hue, saturation, _) = color.hsv() + return Offset(hue / 360 * rect.width, (1 - saturation) * rect.height) + } + } +} + +@Stable +private fun Color.hsv(): Triple { + + // Calculate the maximum and minimum RGB values. + val red = red + val green = green + val blue = blue + val max = maxOf(red, green, blue) + val min = minOf(red, green, blue) + + // Calculate the hue. + val hue = (when { + max == min -> 0f + red == max -> 60f * ((green - blue) / (max - min)) + green == max -> 60f * (2f + (blue - red) / (max - min)) + else -> 60f * (4f + (red - green) / (max - min)) + } + 360).mod(360f) + + // Calculate the saturation. + val saturation = if (max == 0f) 0f else (max - min) / max + + // Calculate the value. + val value = max + + // Return the HSV color. + return Triple(hue, saturation, value) +} + diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/ComboBox.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/ComboBox.kt new file mode 100644 index 00000000..00277353 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/ComboBox.kt @@ -0,0 +1,209 @@ +package com.konyaco.fluent.component + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.animation.FluentDuration +import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.BackgroundSizing +import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.collectVisualState + +/** + * Use a combo box (also known as a drop-down list) to present a list of items that a user can select from. A combo box starts in a compact state and expands to show a list of selectable items. + */ +/* +@Composable +fun ComboBox( + header: (@Composable () -> Unit)? = null, + placeholder: (@Composable () -> Unit)? = null, + open: Boolean, + editable: Boolean, + items: List, + selected: T?, + onSelectionChange: (T) -> Unit, + contentScope: ComboBoxScope.() -> Unit +) { +} +*/ + +/** + * Use a combo box (also known as a drop-down list) to present a list of items that a user can select from. A combo box starts in a compact state and expands to show a list of selectable items. + * TODO: Editable ComboBox + */ +@Composable +fun ComboBox( + modifier: Modifier = Modifier, + header: String? = null, + placeholder: String? = null, + disabled: Boolean = false, + items: List, + selected: Int?, + onSelectionChange: (index: Int, item: String) -> Unit +) { + var open by remember { mutableStateOf(false) } + var size by remember { mutableStateOf(IntSize(0, 0)) } + Column(modifier) { + if (header != null) { + Text(text = header) + Spacer(Modifier.height(8.dp)) + } + DropDownButton( + modifier = Modifier.defaultMinSize(128.dp).onSizeChanged { size = it }, + onClick = { open = true }, + disabled = disabled, + contentArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val text = if (selected != null) items[selected] + else placeholder ?: "" + + Text( + modifier = Modifier.padding(end = 8.dp), + text = text, + color = if (selected != null) FluentTheme.colors.text.text.primary + else FluentTheme.colors.text.text.secondary + ) + } + } + // TODO: Use new flyout popup + // TODO: Set transform center to currently selected item + DropdownMenu( + modifier = Modifier.width(with(LocalDensity.current) { size.width.toDp() + 4.dp }), + expanded = open, + onDismissRequest = { open = false }, + offset = DpOffset(x = 0.dp, y = with(LocalDensity.current) { -(size.height.toDp() + 6.dp) }) + ) { + items.fastForEachIndexed { i, s -> + ComboBoxItem(selected = i == selected, label = s, onClick = { + onSelectionChange(i, s) + open = false + }) + } + } + } +} + +data class ItemColor( + val fillColor: Color, + val contentColor: Color +) + + +private val unselectedItemColors: PentaVisualScheme + @Composable + get() = PentaVisualScheme( + default = ItemColor( + fillColor = FluentTheme.colors.subtleFill.transparent, + contentColor = FluentTheme.colors.text.text.primary + ), + hovered = ItemColor( + fillColor = FluentTheme.colors.subtleFill.secondary, + contentColor = FluentTheme.colors.text.text.primary + ), + pressed = ItemColor( + fillColor = FluentTheme.colors.subtleFill.tertiary, + contentColor = FluentTheme.colors.text.text.primary + ), + disabled = ItemColor( + fillColor = FluentTheme.colors.subtleFill.transparent, + contentColor = FluentTheme.colors.text.text.disabled + ) + ) + +private val selectedItemColors: PentaVisualScheme + @Composable + get() = PentaVisualScheme( + default = ItemColor( + fillColor = FluentTheme.colors.subtleFill.secondary, + contentColor = FluentTheme.colors.text.text.primary + ), + hovered = ItemColor( + fillColor = FluentTheme.colors.subtleFill.tertiary, + contentColor = FluentTheme.colors.text.text.primary + ), + pressed = ItemColor( + fillColor = FluentTheme.colors.subtleFill.secondary, + contentColor = FluentTheme.colors.text.text.primary + ), + disabled = ItemColor( + fillColor = FluentTheme.colors.subtleFill.transparent, + contentColor = FluentTheme.colors.text.text.disabled + ) + ) + + +@Composable +fun ComboBoxItem( + selected: Boolean, + label: String, + onClick: () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + + val colors = if (selected) selectedItemColors else unselectedItemColors + val color = colors.schemeFor(interactionSource.collectVisualState(false)) + + Layer( + modifier = Modifier.fillMaxWidth().height(36.dp) + .clickable(interactionSource = interactionSource, indication = null, onClick = onClick), + shape = RoundedCornerShape(size = 4.dp), + color = animateColorAsState( + color.fillColor, + tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + ).value, + contentColor = color.contentColor, + border = null, + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) { + Box(contentAlignment = Alignment.CenterStart) { + val pressed by interactionSource.collectIsPressedAsState() + val height by animateDpAsState(if (pressed) 12.dp else 16.dp) + // Indicator + if (selected) Box( + Modifier.size(height = height, width = 3.dp) + .align(Alignment.CenterStart) + .background(FluentTheme.colors.fillAccent.default, CircleShape) + ) + Text(modifier = Modifier.padding(horizontal = 12.dp), text = label) + } + } +} + +interface ComboBoxScope { + fun Item(key: String, content: @Composable (item: T) -> Unit) + fun StringItem(label: String) +} \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Dialog.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Dialog.kt index dc4711cc..416fd461 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Dialog.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Dialog.kt @@ -1,21 +1,41 @@ package com.konyaco.fluent.component -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider @@ -24,21 +44,34 @@ import com.konyaco.fluent.LocalContentColor import com.konyaco.fluent.LocalTextStyle import com.konyaco.fluent.animation.FluentDuration import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.BackgroundSizing import com.konyaco.fluent.background.Layer import com.konyaco.fluent.background.Mica +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.resume -internal expect val DialogPopupPositionProvider : PopupPositionProvider +internal expect val DialogPopupPositionProvider: PopupPositionProvider + +@Stable +class DialogSize( + val min: Dp, + val max: Dp +) { + companion object { + val Max = DialogSize(540.dp, 540.dp) + val Standard = DialogSize(448.dp, 448.dp) + val Min = DialogSize(320.dp, 320.dp) + } +} -@OptIn(ExperimentalAnimationApi::class) @Composable -fun Dialog( - title: String, +fun FluentDialog( visible: Boolean, - content: @Composable () -> Unit, - cancelButtonText: String, - onCancel: () -> Unit, - confirmButtonText: String, - onConfirm: () -> Unit + size: DialogSize = DialogSize.Standard, + content: @Composable () -> Unit ) { val visibleState = remember { MutableTransitionState(false) } @@ -49,64 +82,170 @@ fun Dialog( if (visibleState.currentState || visibleState.targetState) Popup( popupPositionProvider = DialogPopupPositionProvider ) { + val scrim by animateColorAsState( + if (visible) Color.Black.copy(0.3f) else Color.Transparent, animationSpec = tween( + easing = FluentEasing.FastInvokeEasing, + durationMillis = FluentDuration.ShortDuration + ) + ) + val tween = tween( + easing = FluentEasing.FastInvokeEasing, + durationMillis = FluentDuration.ShortDuration + ) Box( Modifier.fillMaxSize() - .background(Color.Black.copy(0.3f)) + .background(scrim) .pointerInput(Unit) {}, Alignment.Center ) { - - val tween = tween( - easing = FluentEasing.FastInvokeEasing, - durationMillis = FluentDuration.QuickDuration - ) - AnimatedVisibility( visibleState = visibleState, - enter = fadeIn(tween) + scaleIn(tween, initialScale = 1.1f), - exit = fadeOut(tween) + scaleOut(tween, targetScale = 1.1f) + enter = fadeIn(tween) + scaleIn(tween, initialScale = 1.05f), + exit = fadeOut(tween) + scaleOut(tween, targetScale = 1.05f) ) { Mica(Modifier.wrapContentSize().clip(RoundedCornerShape(8.dp))) { Layer( - Modifier.wrapContentSize().widthIn(200.dp, 600.dp), - shape = RoundedCornerShape(8.dp), + Modifier.wrapContentSize().widthIn(size.min, size.max), + shape = RoundedCornerShape(size = 8.dp), border = BorderStroke(1.dp, FluentTheme.colors.stroke.surface.default), - cornerRadius = 8.dp, - outsideBorder = true, + backgroundSizing = BackgroundSizing.InnerBorderEdge, + color = FluentTheme.colors.background.solid.base, contentColor = FluentTheme.colors.text.text.primary, + content = content + ) + } + } + } + } +} + +enum class ContentDialogButton { + Primary, Secondary, Close +} + +@Composable +fun ContentDialog( + title: String, + visible: Boolean, + content: @Composable () -> Unit, + primaryButtonText: String, + secondaryButtonText: String? = null, + closeButtonText: String? = null, + onButtonClick: (ContentDialogButton) -> Unit, + size: DialogSize = DialogSize.Standard +) { + FluentDialog(visible, size) { + Column { + Column(Modifier.background(FluentTheme.colors.background.layer.alt).padding(24.dp)) { + Text( + style = FluentTheme.typography.subtitle, + text = title, + ) + Spacer(Modifier.height(12.dp)) + CompositionLocalProvider( + LocalTextStyle provides FluentTheme.typography.body, + LocalContentColor provides FluentTheme.colors.text.text.primary + ) { + content() + } + } + // Divider + Box(Modifier.height(1.dp).background(FluentTheme.colors.stroke.surface.default)) + // Button Grid + Box(Modifier.height(80.dp).padding(horizontal = 25.dp), Alignment.CenterEnd) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AccentButton( + modifier = Modifier.weight(1f), + onClick = { onButtonClick(ContentDialogButton.Primary) } + ) { + Text(primaryButtonText) + } + if (secondaryButtonText != null) Button( + modifier = Modifier.weight(1f), + onClick = { onButtonClick(ContentDialogButton.Secondary) } + ) { + Text(secondaryButtonText) + } + if (closeButtonText != null) Button( + modifier = Modifier.weight(1f), + onClick = { onButtonClick(ContentDialogButton.Close) } ) { - Column(Modifier.background(FluentTheme.colors.background.solid.base)) { - Column(Modifier.background(FluentTheme.colors.background.layer.alt).padding(24.dp)) { - Text( - style = FluentTheme.typography.subtitle, - text = title, -// color = FluentTheme.colors.text.text.primary - ) - Spacer(Modifier.height(12.dp)) - CompositionLocalProvider( - LocalTextStyle provides FluentTheme.typography.body, - LocalContentColor provides FluentTheme.colors.text.text.primary - ) { - content() - } - } - // Divider - Box(Modifier.height(1.dp).background(FluentTheme.colors.stroke.surface.default)) - // Button Grid - Box(Modifier.height(80.dp).padding(horizontal = 25.dp), Alignment.Center) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - AccentButton(modifier = Modifier.weight(1f), onClick = onConfirm) { - Text(confirmButtonText) - } - Button(modifier = Modifier.weight(1f), onClick = onCancel) { - Text(cancelButtonText) - } - } - } - } + Text(closeButtonText) } } } } } -} \ No newline at end of file +} + +@Composable +fun ContentDialogHost(state: ContentDialogHostState) { + val data = state.currentData + + if (data != null) { + var visible by remember(data) { mutableStateOf(true) } + + ContentDialog( + title = data.title, + visible = visible, + size = data.size, + content = { Text(data.contentText) }, + primaryButtonText = data.primaryButtonText, + secondaryButtonText = data.secondaryButtonText, + closeButtonText = data.closeButtonText, + onButtonClick = { + visible = false + if (data.continuation.isActive) { + data.continuation.resume(it) + } + } + ) + } +} + +val LocalContentDialog = staticCompositionLocalOf { error("Not provided") } + +class ContentDialogHostState { + private val mutex = Mutex() + + internal var currentData by mutableStateOf(null) + private set + + suspend fun show( + title: String, + contentText: String, + primaryButtonText: String, + secondaryButtonText: String? = null, + closeButtonText: String? = null, + size: DialogSize = DialogSize.Standard, + ): ContentDialogButton { + mutex.withLock { + try { + return suspendCancellableCoroutine { cont -> + currentData = DialogData( + title, + contentText, + primaryButtonText, + secondaryButtonText, + closeButtonText, + size, + cont + ) + } + } finally { + // FIXME: If set null instantly, exit animation will be terminated +// currentData = null + } + } + } + + internal class DialogData( + val title: String, + val contentText: String, + val primaryButtonText: String, + val secondaryButtonText: String? = null, + val closeButtonText: String? = null, + val size: DialogSize = DialogSize.Standard, + val continuation: CancellableContinuation + ) +} diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Dropdown.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Dropdown.kt index 89f6dbc0..9014fe52 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Dropdown.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Dropdown.kt @@ -6,7 +6,16 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeOut import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -18,13 +27,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.* -import androidx.compose.ui.window.Popup +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties import com.konyaco.fluent.FluentTheme import com.konyaco.fluent.animation.FluentDuration import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.BackgroundSizing import com.konyaco.fluent.background.Layer import com.konyaco.fluent.background.Mica @@ -33,6 +50,9 @@ fun DropdownMenu( expanded: Boolean, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, + focusable: Boolean = false, + onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false }, + onKeyEvent: ((KeyEvent) -> Boolean) = { false }, offset: DpOffset = DpOffset(0.dp, 0.dp), // TODO: Offset content: @Composable ColumnScope.() -> Unit ) { @@ -43,10 +63,13 @@ fun DropdownMenu( val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) } // TODO: Transform Origin val density = LocalDensity.current - val popupPositionProvider = DropdownMenuPositionProvider(density) + val popupPositionProvider = DropdownMenuPositionProvider(density, offset) Popup( + properties = PopupProperties(focusable = focusable), onDismissRequest = onDismissRequest, + onKeyEvent = onKeyEvent, + onPreviewKeyEvent = onPreviewKeyEvent, popupPositionProvider = popupPositionProvider, ) { DropdownMenuContent( @@ -59,7 +82,7 @@ fun DropdownMenu( } } -internal class DropdownMenuPositionProvider(val density: Density) : PopupPositionProvider { +internal class DropdownMenuPositionProvider(val density: Density, val offset: DpOffset) : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, windowSize: IntSize, @@ -81,8 +104,11 @@ internal class DropdownMenuPositionProvider(val density: Density) : PopupPositio } else { anchorBounds.bottom + gap } - - return IntOffset(x, y) + with(density) { + offset.x.roundToPx() + offset.y.roundToPx() + return IntOffset(x + offset.x.roundToPx(), y + offset.y.roundToPx()) + } } } @@ -105,15 +131,14 @@ internal fun DropdownMenuContent( Layer( shape = RoundedCornerShape(8.dp), border = BorderStroke(1.dp, FluentTheme.colors.stroke.surface.flyout), - outsideBorder = true, - cornerRadius = 8.dp + backgroundSizing = BackgroundSizing.InnerBorderEdge ) { Column( modifier = modifier .padding(vertical = 4.dp, horizontal = 4.dp) .width(IntrinsicSize.Max) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), content = content ) } diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Flyout.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Flyout.kt index 79228973..8fb7ef3f 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Flyout.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Flyout.kt @@ -1,55 +1,49 @@ package com.konyaco.fluent.component -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition +import androidx.compose.animation.* import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.geometry.center +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.center +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties +import com.konyaco.fluent.* +import com.konyaco.fluent.LocalAcrylicPopupEnabled +import com.konyaco.fluent.LocalWindowAcrylicContainer import com.konyaco.fluent.animation.FluentDuration import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.AcrylicDefaults +import com.konyaco.fluent.background.BackgroundSizing import com.konyaco.fluent.background.Layer import com.konyaco.fluent.background.Mica @Composable fun FlyoutContainer( - flyout: @Composable () -> Unit, + flyout: @Composable FlyoutContainerScope.() -> Unit, modifier: Modifier = Modifier, initialVisible: Boolean = false, placement: FlyoutPlacement = FlyoutPlacement.Auto, - content: @Composable FlyoutScope.() -> Unit + adaptivePlacement: Boolean = false, + onKeyEvent: ((keyEvent: KeyEvent) -> Boolean)? = null, + onPreviewKeyEvent: ((keyEvent: KeyEvent) -> Boolean)? = null, + content: @Composable FlyoutContainerScope.() -> Unit ) { BasicFlyoutContainer( flyout = { @@ -57,7 +51,10 @@ fun FlyoutContainer( visible = isFlyoutVisible, onDismissRequest = { isFlyoutVisible = false }, placement = placement, - content = flyout + adaptivePlacement = adaptivePlacement, + onKeyEvent = onKeyEvent, + onPreviewKeyEvent = onPreviewKeyEvent, + content = { flyout() } ) }, content = content, @@ -68,16 +65,16 @@ fun FlyoutContainer( @Composable internal fun BasicFlyoutContainer( - flyout: @Composable FlyoutScope.() -> Unit, + flyout: @Composable FlyoutContainerScope.() -> Unit, modifier: Modifier = Modifier, initialVisible: Boolean = false, - content: @Composable FlyoutScope.() -> Unit + content: @Composable FlyoutContainerScope.() -> Unit ) { val flyoutState = remember(initialVisible) { mutableStateOf(initialVisible) } val flyoutScope = remember(flyoutState) { - FlyoutScopeImpl(flyoutState) + FlyoutContainerScopeImpl(flyoutState) } Box(modifier = modifier) { flyoutScope.content() @@ -108,15 +105,23 @@ fun Flyout( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, placement: FlyoutPlacement = FlyoutPlacement.Auto, + adaptivePlacement: Boolean = false, shape: Shape = RoundedCornerShape(8.dp), + onKeyEvent: ((keyEvent: KeyEvent) -> Boolean)? = null, + onPreviewKeyEvent: ((keyEvent: KeyEvent) -> Boolean)? = null, content: @Composable () -> Unit ) { BasicFlyout( visible = visible, onDismissRequest = onDismissRequest, modifier = modifier, - positionProvider = rememberFlyoutPositionProvider(initialPlacement = placement), + positionProvider = rememberFlyoutPositionProvider( + initialPlacement = placement, + adaptivePlacement = adaptivePlacement + ), shape = shape, + onKeyEvent = onKeyEvent, + onPreviewKeyEvent = onPreviewKeyEvent, content = content ) } @@ -130,6 +135,8 @@ internal fun BasicFlyout( shape: Shape = RoundedCornerShape(8.dp), contentPadding: PaddingValues = PaddingValues(12.dp), positionProvider: FlyoutPositionProvider = rememberFlyoutPositionProvider(), + onKeyEvent: ((keyEvent: KeyEvent) -> Boolean)? = null, + onPreviewKeyEvent: ((keyEvent: KeyEvent) -> Boolean)? = null, content: @Composable () -> Unit ) { val visibleState = remember { @@ -139,8 +146,13 @@ internal fun BasicFlyout( if (visibleState.currentState || visibleState.targetState) { Popup( onDismissRequest = onDismissRequest, - properties = PopupProperties(clippingEnabled = false), + properties = PopupProperties( + clippingEnabled = false, + focusable = onKeyEvent != null || onPreviewKeyEvent != null + ), popupPositionProvider = positionProvider, + onKeyEvent = onKeyEvent, + onPreviewKeyEvent = onPreviewKeyEvent ) { if (positionProvider.applyAnimation) { FlyoutContent( @@ -176,387 +188,84 @@ internal fun FlyoutContent( contentPadding: PaddingValues = PaddingValues(12.dp), content: @Composable () -> Unit ) { - - AnimatedVisibility( + AcrylicPopupContent( visibleState = visibleState, - enter = enterPlacementAnimation(placement), - exit = fadeOut(flyoutExitSpec()) - ) { - Mica( - modifier = modifier.padding(flyoutPopPaddingFixShadowRender).graphicsLayer { - shadowElevation = 8.dp.toPx() - this.shape = shape - clip = true - } - ) { - Layer(shape = shape) { - Box(modifier = Modifier.padding(contentPadding)) { - content() - } - } - } - } + enterTransition = enterPlacementAnimation(placement), + exitTransition = fadeOut(flyoutExitSpec()), + content = content, + contentPadding = contentPadding, + elevation = 8.dp, + shape = shape, + modifier = modifier + ) } +@OptIn(ExperimentalFluentApi::class) @Composable -internal fun rememberFlyoutPositionProvider( - initialPlacement: FlyoutPlacement = FlyoutPlacement.Auto, - paddingToAnchor: PaddingValues = PaddingValues(flyoutDefaultPadding) -): FlyoutPositionProvider { - val density = LocalDensity.current - return remember(initialPlacement, density, paddingToAnchor) { - FlyoutPositionProvider(density, initialPlacement, paddingToAnchor) - } -} - -@Stable -internal open class FlyoutPositionProvider( - private val density: Density, - private val initialPlacement: FlyoutPlacement = FlyoutPlacement.Auto, - private val paddingToAnchor: PaddingValues = PaddingValues(flyoutDefaultPadding), -) : PopupPositionProvider { - - var applyAnimation by mutableStateOf(false) - private set - - var targetPlacement by mutableStateOf(initialPlacement) - private set - - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize - ): IntOffset { - applyAnimation = false - with(density) { - val popupPadding = flyoutPopPaddingFixShadowRender.toPx() - val popupActualSize = Size( - popupContentSize.width - popupPadding * 2, - popupContentSize.height - popupPadding * 2 - ) - val horizontalPlacement: HorizontalPlacement - val verticalPlacement: VerticalPlacement - - targetPlacement = if (initialPlacement != FlyoutPlacement.Auto) { - initialPlacement.apply { - val (targetHorizontalPlacement, targetVerticalPlacement) = transformPlacement( - initialPlacement - ) - horizontalPlacement = targetHorizontalPlacement - verticalPlacement = targetVerticalPlacement +internal fun AcrylicPopupContent( + visibleState: MutableTransitionState, + enterTransition: EnterTransition, + exitTransition: ExitTransition, + modifier: Modifier = Modifier, + elevation: Dp, + shape: Shape, + contentPadding: PaddingValues, + content: @Composable () -> Unit +) { + with(LocalWindowAcrylicContainer.current) { + val userAcrylic = LocalAcrylicPopupEnabled.current + AnimatedVisibility( + visibleState = visibleState, + enter = enterTransition, + exit = exitTransition, + modifier = Modifier.then( + if (userAcrylic) { + Modifier.padding(flyoutPopPaddingFixShadowRender) + } else { + Modifier } - } else { - val (targetHorizontalPlacement, targetVerticalPlacement) = calculateTargetPlacement( - anchorBounds, - windowSize, - layoutDirection, - popupActualSize - ) - horizontalPlacement = targetHorizontalPlacement - verticalPlacement = targetVerticalPlacement - transformPlacement(horizontalPlacement, verticalPlacement) - } - applyAnimation = true - if (targetPlacement == FlyoutPlacement.Full) { - return IntOffset((windowSize.width - popupContentSize.width) / 2, (windowSize.height - popupContentSize.height) / 2) - } - val popupActualCenter = popupActualSize.center - val anchorCenter = anchorBounds.center - return IntOffset( - x = (getOffsetX( - horizontalPlacement, - layoutDirection, - anchorBounds, - anchorCenter, - popupActualSize, - popupActualCenter, - windowSize - ) - popupPadding).toInt(), - y = (getOffsetY( - verticalPlacement, - anchorBounds, - anchorCenter, - popupActualSize, - popupActualCenter, - windowSize - ) - popupPadding).toInt() ) - } - } - - private fun Density.getOffsetX( - placement: HorizontalPlacement, - layoutDirection: LayoutDirection, - anchorBounds: IntRect, - anchorCenter: IntOffset, - popupContentSize: Size, - popupContentCenter: Offset, - windowSize: IntSize - ): Float { - val isLTR = layoutDirection == LayoutDirection.Ltr - return if (isLTR) { - when (placement) { - HorizontalPlacement.Start -> anchorBounds.left - popupContentSize.width - paddingToAnchor.calculateLeftPadding( - layoutDirection - ).toPx() - - HorizontalPlacement.End -> anchorBounds.right + paddingToAnchor.calculateRightPadding( - layoutDirection - ).toPx() - - HorizontalPlacement.Center -> anchorCenter.x - popupContentCenter.x - HorizontalPlacement.AlignedStart -> anchorBounds.left.toFloat() - HorizontalPlacement.AlignedEnd -> anchorBounds.right - popupContentSize.width - else -> windowSize.center.x - popupContentCenter.x - } - } else { - when (placement) { - HorizontalPlacement.End -> anchorBounds.left - popupContentSize.width - paddingToAnchor.calculateLeftPadding( - layoutDirection - ).toPx() - - HorizontalPlacement.Start -> anchorBounds.right + paddingToAnchor.calculateRightPadding( - layoutDirection - ).toPx() - - HorizontalPlacement.Center -> anchorCenter.x - popupContentCenter.x - HorizontalPlacement.AlignedEnd -> anchorBounds.left.toFloat() - HorizontalPlacement.AlignedStart -> anchorBounds.right - popupContentSize.width - else -> windowSize.center.x - popupContentCenter.x - } - } - } - - private fun Density.getOffsetY( - placement: VerticalPlacement, - anchorBounds: IntRect, - anchorCenter: IntOffset, - popupContentSize: Size, - popupContentCenter: Offset, - windowSize: IntSize - ): Float { - return when (placement) { - VerticalPlacement.Top -> (anchorBounds.top - popupContentSize.height - paddingToAnchor.calculateTopPadding() - .toPx()) - - VerticalPlacement.Center -> anchorCenter.y - popupContentCenter.y - VerticalPlacement.Bottom -> anchorBounds.bottom + paddingToAnchor.calculateBottomPadding() - .toPx() - - VerticalPlacement.AlignedTop -> anchorBounds.top.toFloat() - VerticalPlacement.AlignedBottom -> anchorBounds.bottom - popupContentSize.height - else -> windowSize.center.y - popupContentCenter.y - } - } - - protected open fun Density.calculateTargetPlacement( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: Size - ): Pair { - return calculateVerticalPlacement( - anchorBounds, - windowSize, - layoutDirection, - popupContentSize - ) - } - - private fun Density.calculateVerticalPlacement( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: Size - ): Pair { - val hasTopSpace = anchorBounds.top - paddingToAnchor.calculateTopPadding() - .toPx() >= popupContentSize.height - val halfContentWidth = popupContentSize.width / 2f - val center = anchorBounds.center - - fun calculateStartOrEndOrCenter(): HorizontalPlacement { - val hasLeftSpace = center.x >= halfContentWidth - val hasRightSpace = windowSize.width - center.x >= halfContentWidth - return when { - hasRightSpace && hasLeftSpace -> HorizontalPlacement.Center - (hasLeftSpace && layoutDirection == LayoutDirection.Ltr) or - (hasRightSpace && layoutDirection == LayoutDirection.Rtl) -> HorizontalPlacement.Start - - hasRightSpace -> HorizontalPlacement.Center - hasLeftSpace -> HorizontalPlacement.End - else -> HorizontalPlacement.None - } - } - - if (hasTopSpace) { - return calculateStartOrEndOrCenter() to VerticalPlacement.Top - } - val hasBottomSpace = anchorBounds.bottom - paddingToAnchor.calculateBottomPadding() - .toPx() >= popupContentSize.height - - if (hasBottomSpace) { - return calculateStartOrEndOrCenter() to VerticalPlacement.Bottom - } - return HorizontalPlacement.Center to VerticalPlacement.Top - } - - protected fun Density.calculateHorizontalPlacement( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: Size - ): Pair { - val isLRT = layoutDirection == LayoutDirection.Ltr - val halfContentWidth = popupContentSize.width / 2f - val halfContentHeight = popupContentSize.height / 2f - val center = anchorBounds.center - val hasLeftSpace = - center.x >= halfContentWidth + paddingToAnchor.calculateLeftPadding(layoutDirection) - .toPx() - val hasRightSpace = - windowSize.width - center.x >= halfContentWidth + paddingToAnchor.calculateRightPadding( - layoutDirection - ).toPx() - - fun calculateTopOrBottomOrCenter(): VerticalPlacement { - val hasAlignedTopSpace = windowSize.height - anchorBounds.top >= popupContentSize.height - val hasAlignedBottomSpace = anchorBounds.bottom >= popupContentSize.height - if (hasAlignedTopSpace) { - return VerticalPlacement.AlignedTop - } - if (hasAlignedBottomSpace) { - return VerticalPlacement.AlignedBottom - } - if (center.y - halfContentHeight > 0) { - return VerticalPlacement.Center - } - return VerticalPlacement.None - } - - if (isLRT) { - if (hasRightSpace) { - return HorizontalPlacement.End to calculateTopOrBottomOrCenter() - } - - if (hasLeftSpace) { - return HorizontalPlacement.Start to calculateTopOrBottomOrCenter() - } - } else { - if (hasLeftSpace) { - return HorizontalPlacement.End to calculateTopOrBottomOrCenter() - } - if (hasRightSpace) { - return HorizontalPlacement.Start to calculateTopOrBottomOrCenter() - } - } - - return HorizontalPlacement.End to VerticalPlacement.AlignedTop - } - - private fun transformPlacement( - horizontalPlacement: HorizontalPlacement, - verticalPlacement: VerticalPlacement - ): FlyoutPlacement { - return when (horizontalPlacement) { - HorizontalPlacement.Start -> { - when (verticalPlacement) { - VerticalPlacement.AlignedTop -> FlyoutPlacement.StartAlignedTop - VerticalPlacement.AlignedBottom -> FlyoutPlacement.StartAlignedBottom - else -> FlyoutPlacement.Start - } - } - - HorizontalPlacement.End -> { - when (verticalPlacement) { - VerticalPlacement.AlignedTop -> FlyoutPlacement.EndAlignedTop - VerticalPlacement.AlignedBottom -> FlyoutPlacement.EndAlignedBottom - else -> FlyoutPlacement.End - } - } - - HorizontalPlacement.Center -> { - when (verticalPlacement) { - VerticalPlacement.Top -> FlyoutPlacement.Top - VerticalPlacement.Bottom -> FlyoutPlacement.Bottom - else -> FlyoutPlacement.Full - } - } - - HorizontalPlacement.AlignedStart -> { - when (verticalPlacement) { - VerticalPlacement.Top -> FlyoutPlacement.TopAlignedStart - VerticalPlacement.Bottom -> FlyoutPlacement.BottomAlignedStart - else -> FlyoutPlacement.Full + ) { + if (!userAcrylic) { + Mica(modifier = Modifier.padding(flyoutPopPaddingFixShadowRender).graphicsLayer { + this.shape = shape + shadowElevation = elevation.toPx() + clip = true + }) { + Layer( + shape = shape, + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) { + Box(modifier = modifier.padding(contentPadding)) { + content() + } + } } - } - - HorizontalPlacement.AlignedEnd -> { - when (verticalPlacement) { - VerticalPlacement.Top -> FlyoutPlacement.TopAlignedEnd - VerticalPlacement.Bottom -> FlyoutPlacement.BottomAlignedEnd - else -> FlyoutPlacement.Full + } else { + Box( + modifier = modifier + .border(BorderStroke(1.dp, FluentTheme.colors.stroke.card.default), shape = shape) + .acrylicOverlay( + tint = AcrylicDefaults.tintColor, + enabled = { visibleState.targetState || (visibleState.currentState && visibleState.isIdle) }, + shape = shape + ) + .padding(contentPadding) + .clip(shape) + ) { + content() } } - - else -> FlyoutPlacement.Full - } - } - - private fun transformPlacement(flyoutPlacement: FlyoutPlacement): Pair { - return when (flyoutPlacement) { - FlyoutPlacement.Top -> HorizontalPlacement.Center to VerticalPlacement.Top - FlyoutPlacement.Bottom -> HorizontalPlacement.Center to VerticalPlacement.Bottom - FlyoutPlacement.Start -> HorizontalPlacement.Start to VerticalPlacement.Center - FlyoutPlacement.End -> HorizontalPlacement.End to VerticalPlacement.Center - FlyoutPlacement.TopAlignedStart -> HorizontalPlacement.AlignedStart to VerticalPlacement.Top - FlyoutPlacement.TopAlignedEnd -> HorizontalPlacement.AlignedEnd to VerticalPlacement.Top - FlyoutPlacement.BottomAlignedStart -> HorizontalPlacement.AlignedStart to VerticalPlacement.Bottom - FlyoutPlacement.BottomAlignedEnd -> HorizontalPlacement.AlignedEnd to VerticalPlacement.Bottom - FlyoutPlacement.StartAlignedTop -> HorizontalPlacement.Start to VerticalPlacement.AlignedTop - FlyoutPlacement.StartAlignedBottom -> HorizontalPlacement.Start to VerticalPlacement.AlignedBottom - FlyoutPlacement.EndAlignedTop -> HorizontalPlacement.End to VerticalPlacement.AlignedTop - FlyoutPlacement.EndAlignedBottom -> HorizontalPlacement.End to VerticalPlacement.AlignedBottom - FlyoutPlacement.Full -> HorizontalPlacement.Center to VerticalPlacement.Center - else -> HorizontalPlacement.None to VerticalPlacement.None - } - } - - @JvmInline - protected value class HorizontalPlacement( - private val value: Int - ) { - companion object { - val Start = HorizontalPlacement(0) - val Center = HorizontalPlacement(1) - val End = HorizontalPlacement(2) - val None = HorizontalPlacement(-1) - val AlignedStart = HorizontalPlacement(3) - val AlignedEnd = HorizontalPlacement(4) - } - } - - @JvmInline - protected value class VerticalPlacement( - private val value: Int - ) { - companion object { - val Top = VerticalPlacement(0) - val Center = VerticalPlacement(1) - val Bottom = VerticalPlacement(2) - val AlignedTop = VerticalPlacement(3) - val AlignedBottom = VerticalPlacement(4) - val None = VerticalPlacement(-1) } } } -private class FlyoutScopeImpl(visibleState: MutableState) : FlyoutScope { +private class FlyoutContainerScopeImpl(visibleState: MutableState) : FlyoutContainerScope { override var isFlyoutVisible: Boolean by visibleState } -interface FlyoutScope { +interface FlyoutContainerScope { var isFlyoutVisible: Boolean diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/FlyoutPositionProvider.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/FlyoutPositionProvider.kt new file mode 100644 index 00000000..d6e021fc --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/FlyoutPositionProvider.kt @@ -0,0 +1,537 @@ +package com.konyaco.fluent.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.* +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.* +import androidx.compose.ui.window.PopupPositionProvider + +@Composable +internal fun rememberFlyoutPositionProvider( + initialPlacement: FlyoutPlacement = FlyoutPlacement.Auto, + paddingToAnchor: PaddingValues = PaddingValues(flyoutDefaultPadding), + adaptivePlacement: Boolean = false +): FlyoutPositionProvider { + val density = LocalDensity.current + return remember(initialPlacement, density, paddingToAnchor, adaptivePlacement) { + FlyoutPositionProvider(density, initialPlacement, paddingToAnchor, adaptivePlacement) + } +} + +@Stable +internal open class FlyoutPositionProvider( + private val density: Density, + private val initialPlacement: FlyoutPlacement = FlyoutPlacement.Auto, + private val paddingToAnchor: PaddingValues = PaddingValues(flyoutDefaultPadding), + private val adaptivePlacement: Boolean = false, +) : PopupPositionProvider { + + var applyAnimation by mutableStateOf(false) + private set + + var targetPlacement by mutableStateOf(initialPlacement) + private set + + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + applyAnimation = false + with(density) { + val popupPadding = flyoutPopPaddingFixShadowRender.toPx() + val popupActualSize = Size( + popupContentSize.width - popupPadding * 2, + popupContentSize.height - popupPadding * 2 + ) + var (horizontalPlacement, verticalPlacement) = transformPlacement(initialPlacement) + targetPlacement = when { + initialPlacement != FlyoutPlacement.Auto && !adaptivePlacement -> initialPlacement + + initialPlacement != FlyoutPlacement.Auto && adaptivePlacement -> { + val (hasHorizontalSpace, hasVerticalSpace) = hasSpaceForTargetPlacement( + horizontalPlacement, + verticalPlacement, + layoutDirection, + popupActualSize, + anchorBounds, + windowSize + ) + when { + hasHorizontalSpace && hasVerticalSpace -> initialPlacement + hasHorizontalSpace -> transformPlacement( + horizontalPlacement, + calculateTopOrBottomPlacement( + anchorBounds, windowSize, popupActualSize + ).apply { + verticalPlacement = this + } + ) + + hasVerticalSpace -> transformPlacement( + calculateStartOrEndPlacement( + anchorBounds, windowSize, layoutDirection, popupActualSize + ).apply { + horizontalPlacement = this + }, + verticalPlacement + ) + + else -> { + val (targetHorizontalPlacement, targetVerticalPlacement) = calculateTargetPlacement( + anchorBounds, + windowSize, + layoutDirection, + popupActualSize + ) + horizontalPlacement = targetHorizontalPlacement + verticalPlacement = targetVerticalPlacement + transformPlacement(horizontalPlacement, verticalPlacement) + } + } + } + + else -> { + val (targetHorizontalPlacement, targetVerticalPlacement) = calculateTargetPlacement( + anchorBounds, + windowSize, + layoutDirection, + popupActualSize + ) + horizontalPlacement = targetHorizontalPlacement + verticalPlacement = targetVerticalPlacement + transformPlacement(horizontalPlacement, verticalPlacement) + } + } + applyAnimation = true + if (targetPlacement == FlyoutPlacement.Full) { + return IntOffset( + (windowSize.width - popupContentSize.width) / 2, + (windowSize.height - popupContentSize.height) / 2 + ) + } + val popupActualCenter = popupActualSize.center + val anchorCenter = anchorBounds.center + return IntOffset( + x = (getOffsetX( + horizontalPlacement, + layoutDirection, + anchorBounds, + anchorCenter, + popupActualSize, + popupActualCenter, + windowSize + ) - popupPadding).toInt(), + y = (getOffsetY( + verticalPlacement, + anchorBounds, + anchorCenter, + popupActualSize, + popupActualCenter, + windowSize + ) - popupPadding).toInt() + ) + } + } + + private fun Density.getOffsetX( + placement: HorizontalPlacement, + layoutDirection: LayoutDirection, + anchorBounds: IntRect, + anchorCenter: IntOffset, + popupContentSize: Size, + popupContentCenter: Offset, + windowSize: IntSize + ): Float { + val isLTR = layoutDirection == LayoutDirection.Ltr + return if (isLTR) { + when (placement) { + HorizontalPlacement.Start -> anchorBounds.left - popupContentSize.width - paddingToAnchor.calculateLeftPadding( + layoutDirection + ).toPx() + + HorizontalPlacement.End -> anchorBounds.right + paddingToAnchor.calculateRightPadding( + layoutDirection + ).toPx() + + HorizontalPlacement.Center -> anchorCenter.x - popupContentCenter.x + HorizontalPlacement.AlignedStart -> anchorBounds.left.toFloat() + HorizontalPlacement.AlignedEnd -> anchorBounds.right - popupContentSize.width + else -> windowSize.center.x - popupContentCenter.x + } + } else { + when (placement) { + HorizontalPlacement.End -> anchorBounds.left - popupContentSize.width - paddingToAnchor.calculateLeftPadding( + layoutDirection + ).toPx() + + HorizontalPlacement.Start -> anchorBounds.right + paddingToAnchor.calculateRightPadding( + layoutDirection + ).toPx() + + HorizontalPlacement.Center -> anchorCenter.x - popupContentCenter.x + HorizontalPlacement.AlignedEnd -> anchorBounds.left.toFloat() + HorizontalPlacement.AlignedStart -> anchorBounds.right - popupContentSize.width + else -> windowSize.center.x - popupContentCenter.x + } + } + } + + private fun Density.getOffsetY( + placement: VerticalPlacement, + anchorBounds: IntRect, + anchorCenter: IntOffset, + popupContentSize: Size, + popupContentCenter: Offset, + windowSize: IntSize + ): Float { + return when (placement) { + VerticalPlacement.Top -> (anchorBounds.top - popupContentSize.height - paddingToAnchor.calculateTopPadding() + .toPx()) + + VerticalPlacement.Center -> anchorCenter.y - popupContentCenter.y + VerticalPlacement.Bottom -> anchorBounds.bottom + paddingToAnchor.calculateBottomPadding() + .toPx() + + VerticalPlacement.AlignedTop -> anchorBounds.top.toFloat() + VerticalPlacement.AlignedBottom -> anchorBounds.bottom - popupContentSize.height + else -> windowSize.center.y - popupContentCenter.y + } + } + + private fun hasSpaceForTargetPlacement( + horizontalPlacement: HorizontalPlacement, + verticalPlacement: VerticalPlacement, + layoutDirection: LayoutDirection, + popupContentSize: Size, + anchorBounds: IntRect, + windowSize: IntSize + ): Pair { + with(density) { + val hasVertical = when (verticalPlacement) { + VerticalPlacement.Top -> popupContentSize.height + paddingToAnchor.calculateTopPadding() + .toPx() >= anchorBounds.top + + VerticalPlacement.AlignedTop -> windowSize.height >= anchorBounds.top + paddingToAnchor.calculateTopPadding() + .toPx() + popupContentSize.height + + VerticalPlacement.Bottom -> windowSize.height >= anchorBounds.bottom + paddingToAnchor.calculateBottomPadding() + .toPx() + popupContentSize.height + + VerticalPlacement.AlignedBottom -> anchorBounds.bottom >= popupContentSize.height + paddingToAnchor.calculateBottomPadding() + .toPx() + + VerticalPlacement.Center -> { + val center = anchorBounds.center + val halfHeight = popupContentSize.height / 2 + windowSize.height >= center.y + halfHeight && center.y - halfHeight >= 0 + } + + else -> false + } + val isLrt = layoutDirection == LayoutDirection.Ltr + val hasHorizontal = when { + (isLrt && horizontalPlacement == HorizontalPlacement.Start) || (!isLrt && horizontalPlacement == HorizontalPlacement.End) -> { + anchorBounds.left >= popupContentSize.width + paddingToAnchor.calculateLeftPadding(layoutDirection) + .toPx() + } + + (!isLrt && horizontalPlacement == HorizontalPlacement.Start) || (isLrt && horizontalPlacement == HorizontalPlacement.End) + -> { + windowSize.width >= anchorBounds.right + paddingToAnchor.calculateRightPadding(layoutDirection) + .toPx() + } + + (isLrt && horizontalPlacement == HorizontalPlacement.AlignedStart) || (!isLrt && horizontalPlacement == HorizontalPlacement.AlignedEnd) -> { + windowSize.width >= anchorBounds.left + paddingToAnchor.calculateLeftPadding(layoutDirection) + .toPx() + popupContentSize.width + } + + (!isLrt && horizontalPlacement == HorizontalPlacement.AlignedStart) || (isLrt && horizontalPlacement == HorizontalPlacement.AlignedEnd) + -> { + anchorBounds.right - paddingToAnchor.calculateRightPadding(layoutDirection) + .toPx() - popupContentSize.width >= 0 + } + + horizontalPlacement == HorizontalPlacement.Center -> { + val center = anchorBounds.center + val halfWidth = popupContentSize.width / 2 + windowSize.width >= center.x + halfWidth && center.x - halfWidth >= 0 + } + + else -> false + } + return hasHorizontal to hasVertical + } + } + + protected open fun Density.calculateTargetPlacement( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: Size + ): Pair { + return calculatePlacementByVertical( + anchorBounds, + windowSize, + layoutDirection, + popupContentSize + ) + } + + private fun Density.calculatePlacementByVertical( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: Size + ): Pair { + val hasTopSpace = anchorBounds.top - paddingToAnchor.calculateTopPadding() + .toPx() >= popupContentSize.height + val hasBottomSpace = (windowSize.height - anchorBounds.bottom) - paddingToAnchor.calculateBottomPadding() + .toPx() >= popupContentSize.height + + if (hasTopSpace) { + return calculateAlignedStartOrEndOrCenter( + anchorBounds, + windowSize, + layoutDirection, + popupContentSize + ) to VerticalPlacement.Top + } + if (hasBottomSpace) { + return calculateAlignedStartOrEndOrCenter( + anchorBounds, + windowSize, + layoutDirection, + popupContentSize + ) to VerticalPlacement.Bottom + } + return calculateAlignedStartOrEndOrCenter( + anchorBounds, + windowSize, + layoutDirection, + popupContentSize + ) to calculateTopOrBottomPlacement(anchorBounds, windowSize, popupContentSize) + } + + private fun calculateTopOrBottomPlacement( + anchorBounds: IntRect, + windowSize: IntSize, + popupContentSize: Size + ): VerticalPlacement { + return with(density) { + val hasTopSpace = anchorBounds.top - paddingToAnchor.calculateTopPadding() + .toPx() >= popupContentSize.height + val hasBottomSpace = (windowSize.height - anchorBounds.bottom) - paddingToAnchor.calculateBottomPadding() + .toPx() >= popupContentSize.height + when { + hasTopSpace -> VerticalPlacement.Top + hasBottomSpace -> VerticalPlacement.Bottom + else -> VerticalPlacement.Top + } + } + } + + private fun calculateAlignedStartOrEndOrCenter( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: Size + ): HorizontalPlacement { + val halfContentWidth = popupContentSize.width / 2f + val center = anchorBounds.center + val hasLeftSpace = center.x >= halfContentWidth + val hasRightSpace = windowSize.width - center.x >= halfContentWidth + return when { + hasRightSpace && hasLeftSpace -> HorizontalPlacement.Center + (hasLeftSpace && layoutDirection == LayoutDirection.Ltr) or + (hasRightSpace && layoutDirection == LayoutDirection.Rtl) -> HorizontalPlacement.Start + + hasRightSpace -> HorizontalPlacement.Center + hasLeftSpace -> HorizontalPlacement.End + else -> HorizontalPlacement.None + } + } + + private fun calculateStartOrEndPlacement( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: Size + ): HorizontalPlacement { + with(density) { + val isLRT = layoutDirection == LayoutDirection.Ltr + val halfContentWidth = popupContentSize.width / 2f + val center = anchorBounds.center + val hasLeftSpace = + center.x >= halfContentWidth + paddingToAnchor.calculateLeftPadding(layoutDirection) + .toPx() + val hasRightSpace = + windowSize.width - center.x >= halfContentWidth + paddingToAnchor.calculateRightPadding( + layoutDirection + ).toPx() + + return when { + hasRightSpace -> { + if (isLRT) { + HorizontalPlacement.End + } else { + HorizontalPlacement.Start + } + } + + hasLeftSpace -> { + if (isLRT) { + HorizontalPlacement.Start + } else { + HorizontalPlacement.End + } + } + + else -> HorizontalPlacement.End + } + } + + } + + protected fun calculatePlacementByHorizontal( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: Size + ): Pair { + val center = anchorBounds.center + return calculateStartOrEndPlacement( + anchorBounds, + windowSize, + layoutDirection, + popupContentSize + ) to calculateAlignedTopOrBottomOrCenter( + anchorBounds, + windowSize, + center, + popupContentSize + ) + } + + private fun calculateAlignedTopOrBottomOrCenter( + anchorBounds: IntRect, + windowSize: IntSize, + anchorCenter: IntOffset, + popupContentSize: Size + ): VerticalPlacement { + val halfContentHeight = popupContentSize.height / 2f + val hasAlignedTopSpace = windowSize.height - anchorBounds.top >= popupContentSize.height + val hasAlignedBottomSpace = anchorBounds.bottom >= popupContentSize.height + if (hasAlignedTopSpace) { + return VerticalPlacement.AlignedTop + } + if (hasAlignedBottomSpace) { + return VerticalPlacement.AlignedBottom + } + if (anchorCenter.y - halfContentHeight > 0) { + return VerticalPlacement.Center + } + return VerticalPlacement.None + } + + private fun transformPlacement( + horizontalPlacement: HorizontalPlacement, + verticalPlacement: VerticalPlacement + ): FlyoutPlacement { + return when (horizontalPlacement) { + HorizontalPlacement.Start -> { + when (verticalPlacement) { + VerticalPlacement.AlignedTop -> FlyoutPlacement.StartAlignedTop + VerticalPlacement.AlignedBottom -> FlyoutPlacement.StartAlignedBottom + else -> FlyoutPlacement.Start + } + } + + HorizontalPlacement.End -> { + when (verticalPlacement) { + VerticalPlacement.AlignedTop -> FlyoutPlacement.EndAlignedTop + VerticalPlacement.AlignedBottom -> FlyoutPlacement.EndAlignedBottom + else -> FlyoutPlacement.End + } + } + + HorizontalPlacement.Center -> { + when (verticalPlacement) { + VerticalPlacement.Top -> FlyoutPlacement.Top + VerticalPlacement.Bottom -> FlyoutPlacement.Bottom + else -> FlyoutPlacement.Full + } + } + + HorizontalPlacement.AlignedStart -> { + when (verticalPlacement) { + VerticalPlacement.Top -> FlyoutPlacement.TopAlignedStart + VerticalPlacement.Bottom -> FlyoutPlacement.BottomAlignedStart + else -> FlyoutPlacement.Full + } + } + + HorizontalPlacement.AlignedEnd -> { + when (verticalPlacement) { + VerticalPlacement.Top -> FlyoutPlacement.TopAlignedEnd + VerticalPlacement.Bottom -> FlyoutPlacement.BottomAlignedEnd + else -> FlyoutPlacement.Full + } + } + + else -> FlyoutPlacement.Full + } + } + + private fun transformPlacement(flyoutPlacement: FlyoutPlacement): Pair { + return when (flyoutPlacement) { + FlyoutPlacement.Top -> HorizontalPlacement.Center to VerticalPlacement.Top + FlyoutPlacement.Bottom -> HorizontalPlacement.Center to VerticalPlacement.Bottom + FlyoutPlacement.Start -> HorizontalPlacement.Start to VerticalPlacement.Center + FlyoutPlacement.End -> HorizontalPlacement.End to VerticalPlacement.Center + FlyoutPlacement.TopAlignedStart -> HorizontalPlacement.AlignedStart to VerticalPlacement.Top + FlyoutPlacement.TopAlignedEnd -> HorizontalPlacement.AlignedEnd to VerticalPlacement.Top + FlyoutPlacement.BottomAlignedStart -> HorizontalPlacement.AlignedStart to VerticalPlacement.Bottom + FlyoutPlacement.BottomAlignedEnd -> HorizontalPlacement.AlignedEnd to VerticalPlacement.Bottom + FlyoutPlacement.StartAlignedTop -> HorizontalPlacement.Start to VerticalPlacement.AlignedTop + FlyoutPlacement.StartAlignedBottom -> HorizontalPlacement.Start to VerticalPlacement.AlignedBottom + FlyoutPlacement.EndAlignedTop -> HorizontalPlacement.End to VerticalPlacement.AlignedTop + FlyoutPlacement.EndAlignedBottom -> HorizontalPlacement.End to VerticalPlacement.AlignedBottom + FlyoutPlacement.Full -> HorizontalPlacement.Center to VerticalPlacement.Center + else -> HorizontalPlacement.None to VerticalPlacement.None + } + } + + @JvmInline + protected value class HorizontalPlacement( + private val value: Int + ) { + companion object { + val Start = HorizontalPlacement(0) + val Center = HorizontalPlacement(1) + val End = HorizontalPlacement(2) + val None = HorizontalPlacement(-1) + val AlignedStart = HorizontalPlacement(3) + val AlignedEnd = HorizontalPlacement(4) + } + } + + @JvmInline + protected value class VerticalPlacement( + private val value: Int + ) { + companion object { + val Top = VerticalPlacement(0) + val Center = VerticalPlacement(1) + val Bottom = VerticalPlacement(2) + val AlignedTop = VerticalPlacement(3) + val AlignedBottom = VerticalPlacement(4) + val None = VerticalPlacement(-1) + } + } +} \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/FontIcon.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/FontIcon.kt new file mode 100644 index 00000000..1a1b3565 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/FontIcon.kt @@ -0,0 +1,70 @@ +package com.konyaco.fluent.component + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +//TODO Public +@Composable +internal fun FontIcon( + glyph: Char, + modifier: Modifier = Modifier, + iconSize: TextUnit = FontIconDefaults.fontSizeStandard, + fallback: (@Composable () -> Unit)? = null, +) { + if (LocalFontIconFontFamily.current != null || fallback == null) { + Text( + text = glyph.toString(), + fontFamily = LocalFontIconFontFamily.current, + fontSize = iconSize, + modifier = Modifier.then(modifier) + .height(with(LocalDensity.current) { iconSize.toDp() }), + onTextLayout = { + } + ) + } else { + fallback() + } +} + +@Composable +internal fun FontIcon( + glyph: Char, + vector: ImageVector?, + contentDescription: String?, + modifier: Modifier = Modifier, + iconSize: TextUnit = FontIconDefaults.fontSizeStandard, + vectorSize: Dp = with(LocalDensity.current) { iconSize.toDp() } +) { + FontIcon( + glyph = glyph, + modifier = modifier, + iconSize = iconSize, + fallback = if (vector == null) { + null + } else { + { Icon(vector, contentDescription, modifier = Modifier.size(vectorSize)) } + } + ) +} + +internal object FontIconDefaults { + val fontSizeStandard = 16.sp + val fontSizeSmall = 12.sp +} + +@Composable +internal expect fun ProvideFontIcon( + content: @Composable () -> Unit +) + +internal val LocalFontIconFontFamily = + staticCompositionLocalOf { error("No Font provide for load font icon") } diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/ListItem.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/ListItem.kt new file mode 100644 index 00000000..3ab032d6 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/ListItem.kt @@ -0,0 +1,355 @@ +package com.konyaco.fluent.component + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.LocalCompactMode +import com.konyaco.fluent.LocalContentAlpha +import com.konyaco.fluent.LocalContentColor +import com.konyaco.fluent.LocalTextStyle +import com.konyaco.fluent.animation.FluentDuration +import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.BackgroundSizing +import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.regular.Checkmark +import com.konyaco.fluent.icons.regular.ChevronRight +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.VisualStateScheme +import com.konyaco.fluent.scheme.collectVisualState + +/** + * Design guide: [WinUI 3 Figma design file](https://www.figma.com/community/file/1159947337437047524)/Primitives/ListItem + */ +@Composable +fun ListItem( + selected: Boolean, + onSelectedChanged: (Boolean) -> Unit, + text: @Composable () -> Unit, + modifier: Modifier = Modifier, + selectionType: ListItemSelectionType = ListItemSelectionType.Standard, + icon: (@Composable () -> Unit)? = null, + training: (@Composable () -> Unit)? = null, + interaction: MutableInteractionSource? = null, + enabled: Boolean = true, + colors: VisualStateScheme = if (selected) { + ListItemDefaults.selectedListItemColors() + } else { + ListItemDefaults.defaultListItemColors() + }, +) { + ListItem( + indicator = if (selectionType == ListItemSelectionType.Standard) { + { ListItemDefaults.Indicator(selected, enabled) } + } else { + null + }, + selectionIcon = when (selectionType) { + ListItemSelectionType.Standard -> null + ListItemSelectionType.Check -> { { + if (selected) { ListItemDefaults.CheckIcon() } + } } + ListItemSelectionType.Radio -> { { + if (selected) { ListItemDefaults.RadioIcon() } + } } + }, + text = text, + icon = icon, + training = training, + interaction = interaction, + enabled = enabled, + onClick = { onSelectedChanged(!selected) }, + colors = colors, + modifier = modifier + ) +} + +/** + * Design guide: [WinUI 3 Figma design file](https://www.figma.com/community/file/1159947337437047524)/Primitives/ListItem + */ +@Composable +fun ListItem( + onClick: () -> Unit, + text: @Composable () -> Unit, + modifier: Modifier = Modifier, + selectionIcon: (@Composable () -> Unit)? = null, + indicator: (@Composable () -> Unit)? = null, + icon: (@Composable () -> Unit)? = null, + training: (@Composable () -> Unit)? = null, + interaction: MutableInteractionSource? = null, + enabled: Boolean = true, + colors: VisualStateScheme = ListItemDefaults.defaultListItemColors(), +) { + val actualInteraction = interaction ?: remember { MutableInteractionSource() } + val color = colors.schemeFor(actualInteraction.collectVisualState(!enabled)) + + val fillColor by animateColorAsState( + color.fillColor, + animationSpec = tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + ) + + val contentColor by animateColorAsState( + color.contentColor, + animationSpec = tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + ) + Layer( + modifier = modifier + .defaultMinSize(minWidth = 108.dp, if (LocalCompactMode.current) ListItemCompactHeight else ListItemHeight) + .padding(horizontal = 5.dp, vertical = 2.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(size = 4.dp), + color = fillColor, + contentColor = contentColor, + border = BorderStroke(1.dp, color.borderBrush), + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + ) { + indicator?.invoke() + Row( + modifier = Modifier + .clickable( + onClick = onClick, + interactionSource = actualInteraction, + indication = null, + enabled = enabled + ) + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.Start), + verticalAlignment = Alignment.CenterVertically + ) { + if (selectionIcon != null && indicator == null) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.padding(horizontal = 2.dp).size(12.dp) + ) { + selectionIcon() + } + } + if (icon != null) { + Box( + modifier = Modifier.size(ListItemDefaults.iconSize), + contentAlignment = Alignment.Center + ) { + icon() + } + } + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + text() + } + CompositionLocalProvider( + LocalContentColor provides color.trainingColor, + LocalContentAlpha provides color.trainingColor.alpha, + LocalTextStyle provides FluentTheme.typography.caption.copy(fontWeight = FontWeight.Normal) + ) { + training?.invoke() + } + } + } + } +} + +@Composable +fun ListHeader( + content: @Composable () -> Unit, + modifier: Modifier = Modifier, + color: Color = FluentTheme.colors.text.text.secondary +) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = modifier + .padding(horizontal = 16.dp) + .height( + height = if (LocalCompactMode.current) { + ListItemCompactHeight + } else { + ListItemHeight + } + ) + ) { + CompositionLocalProvider( + LocalTextStyle provides FluentTheme.typography.caption.copy(fontWeight = FontWeight.Normal), + LocalContentAlpha provides color.alpha, + LocalContentColor provides color, + content = content + ) + } +} + +@Composable +fun ListItemSeparator(modifier: Modifier) { + Box( + Modifier + .then(modifier) + .padding(top = 1.dp, bottom = 2.dp) + .fillMaxWidth().height(1.dp) + .background(FluentTheme.colors.stroke.divider.default) + ) +} + +object ListItemDefaults { + + val iconSize = 16.dp + + @Composable + fun CheckIcon() { + FontIcon( + glyph = '\uE8FB', + vector = Icons.Default.Checkmark, + contentDescription = "Check", + iconSize = 12.sp, + vectorSize = 12.dp + ) + } + + @Composable + fun RadioIcon() { + Box(modifier = Modifier + .size(12.dp) + .wrapContentSize(Alignment.Center) + .size(4.dp) + .background(LocalContentColor.current.copy(LocalContentAlpha.current), CircleShape) + ) + } + + @Composable + fun CascadingIcon() { + FontIcon( + glyph = '\uE974', + vector = Icons.Default.ChevronRight, + contentDescription = "cascading", + vectorSize = 12.dp, + iconSize = 12.sp + ) + } + + @Composable + fun Indicator( + visible: Boolean = true, + enabled: Boolean = true, + color: Color = FluentTheme.colors.fillAccent.default, + disabledColor: Color = FluentTheme.colors.fillAccent.disabled + ) { + val height by updateTransition(visible).animateDp(transitionSpec = { + if (targetState) tween(FluentDuration.ShortDuration, easing = FluentEasing.FastInvokeEasing) + else tween(FluentDuration.QuickDuration, easing = FluentEasing.SoftDismissEasing) + }, targetValueByState = { if (it) 16.dp else 0.dp }) + Box( + modifier = Modifier + .size(width = 3.dp, height = height) + .background( + color = if (enabled) { + color + } else { + disabledColor + }, + shape = CircleShape + ) + ) + + } + + @Composable + @Stable + fun defaultListItemColors( + default: ListItemColor = ListItemColor( + fillColor = FluentTheme.colors.subtleFill.transparent, + contentColor = FluentTheme.colors.text.text.primary, + trainingColor = FluentTheme.colors.text.text.secondary, + borderBrush = SolidColor(Color.Transparent) + ), + hovered: ListItemColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.secondary + ), + pressed: ListItemColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.tertiary, + contentColor = FluentTheme.colors.text.text.secondary + ), + disabled: ListItemColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.disabled, + contentColor = FluentTheme.colors.text.text.disabled, + trainingColor = FluentTheme.colors.text.text.disabled, + ) + ) = ListItemColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) + + @Composable + @Stable + fun selectedListItemColors( + default: ListItemColor = ListItemColor( + fillColor = FluentTheme.colors.subtleFill.secondary, + contentColor = FluentTheme.colors.text.text.primary, + trainingColor = FluentTheme.colors.text.text.secondary, + borderBrush = SolidColor(Color.Transparent) + ), + hovered: ListItemColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.tertiary + ), + pressed: ListItemColor = default.copy( + contentColor = FluentTheme.colors.text.text.secondary + ), + disabled: ListItemColor = default.copy( + contentColor = FluentTheme.colors.text.text.disabled, + trainingColor = FluentTheme.colors.text.text.disabled, + ) + ) = ListItemColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) +} + +enum class ListItemSelectionType { + Standard, Radio, Check +} + +@Immutable +data class ListItemColor( + val fillColor: Color, + val contentColor: Color, + val trainingColor: Color, + val borderBrush: Brush +) + +typealias ListItemColorScheme = PentaVisualScheme + +private val ListItemHeight = 40.dp +private val ListItemCompactHeight = 32.dp \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/MenuFlyout.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/MenuFlyout.kt index 756c8c30..f56fc872 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/MenuFlyout.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/MenuFlyout.kt @@ -1,40 +1,30 @@ package com.konyaco.fluent.component import androidx.compose.animation.EnterTransition -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.scaleIn import androidx.compose.animation.slideInVertically -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntRect @@ -42,23 +32,21 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.benasher44.uuid.uuid4 -import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.LocalContentAlpha -import com.konyaco.fluent.LocalContentColor import com.konyaco.fluent.animation.FluentDuration import com.konyaco.fluent.animation.FluentEasing -import com.konyaco.fluent.background.Layer -import com.konyaco.fluent.icons.Icons -import com.konyaco.fluent.icons.regular.ChevronRight +import com.konyaco.fluent.scheme.VisualStateScheme import kotlinx.coroutines.delay @Composable fun MenuFlyoutContainer( - flyout: @Composable MenuFlyoutScope.() -> Unit, + flyout: @Composable MenuFlyoutContainerScope.() -> Unit, modifier: Modifier = Modifier, initialVisible: Boolean = false, placement: FlyoutPlacement = FlyoutPlacement.Auto, - content: @Composable FlyoutScope.() -> Unit + adaptivePlacement: Boolean = false, + onKeyEvent: ((keyEvent: KeyEvent) -> Boolean)? = null, + onPreviewKeyEvent: ((keyEvent: KeyEvent) -> Boolean)? = null, + content: @Composable FlyoutContainerScope.() -> Unit ) { BasicFlyoutContainer( flyout = { @@ -66,9 +54,16 @@ fun MenuFlyoutContainer( visible = isFlyoutVisible, onDismissRequest = { isFlyoutVisible = false }, placement = placement, - content = flyout, - - ) + adaptivePlacement = adaptivePlacement, + content = { + val containerScope = remember(this@BasicFlyoutContainer, this) { + MenuFlyoutContainerScopeImpl(this@BasicFlyoutContainer, this) + } + containerScope.flyout() + }, + onKeyEvent = onKeyEvent, + onPreviewKeyEvent = onPreviewKeyEvent + ) }, content = content, modifier = modifier, @@ -82,7 +77,10 @@ fun MenuFlyout( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, placement: FlyoutPlacement = FlyoutPlacement.Auto, + adaptivePlacement: Boolean = false, shape: Shape = RoundedCornerShape(8.dp), + onKeyEvent: ((keyEvent: KeyEvent) -> Boolean)? = null, + onPreviewKeyEvent: ((keyEvent: KeyEvent) -> Boolean)? = null, content: @Composable MenuFlyoutScope.() -> Unit ) { MenuFlyout( @@ -90,19 +88,26 @@ fun MenuFlyout( onDismissRequest = onDismissRequest, modifier = modifier, shape = shape, - positionProvider = rememberFlyoutPositionProvider(placement), - content = content + positionProvider = rememberFlyoutPositionProvider( + placement, + adaptivePlacement = adaptivePlacement + ), + content = content, + onKeyEvent = onKeyEvent, + onPreviewKeyEvent = onPreviewKeyEvent ) } @Composable -private fun MenuFlyout( +internal fun MenuFlyout( visible: Boolean, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, shape: Shape = RoundedCornerShape(8.dp), positionProvider: FlyoutPositionProvider = rememberFlyoutPositionProvider(), enterPlacementAnimation: (FlyoutPlacement) -> EnterTransition = ::defaultFlyoutEnterPlacementAnimation, + onKeyEvent: ((keyEvent: KeyEvent) -> Boolean)? = null, + onPreviewKeyEvent: ((keyEvent: KeyEvent) -> Boolean)? = null, content: @Composable MenuFlyoutScope.() -> Unit ) { BasicFlyout( @@ -112,7 +117,9 @@ private fun MenuFlyout( enterPlacementAnimation = enterPlacementAnimation, shape = shape, positionProvider = positionProvider, - contentPadding = PaddingValues(vertical = 2.dp) + contentPadding = PaddingValues(vertical = 3.dp), + onKeyEvent = onKeyEvent, + onPreviewKeyEvent = onPreviewKeyEvent ) { Column( modifier = Modifier.width(IntrinsicSize.Max) @@ -125,104 +132,76 @@ private fun MenuFlyout( @Composable fun MenuFlyoutSeparator(modifier: Modifier = Modifier) { - Box( - Modifier - .then(modifier) - .fillMaxWidth().height(1.dp) - .background(FluentTheme.colors.stroke.surface.default.copy(0.1f)) - ) + ListItemSeparator(modifier) } @Composable fun MenuFlyoutScope.MenuFlyoutItem( - onClick: () -> Unit, - icon: @Composable () -> Unit, + selected: Boolean, + onSelectedChanged: (Boolean) -> Unit, text: @Composable () -> Unit, modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, training: (@Composable () -> Unit)? = null, interaction: MutableInteractionSource? = null, enabled: Boolean = true, - colors: MenuColors = menuColors(), - paddingIcon: Boolean = false, + selectionType: ListItemSelectionType = ListItemSelectionType.Standard, + colors: VisualStateScheme = if (selected) { + ListItemDefaults.selectedListItemColors() + } else { + ListItemDefaults.defaultListItemColors() + } ) { val actualInteraction = interaction ?: remember { MutableInteractionSource() } - val hovered by actualInteraction.collectIsHoveredAsState() - val pressed by actualInteraction.collectIsPressedAsState() - - val menuColor = when { - !enabled -> colors.disabled - pressed -> colors.pressed - hovered -> colors.hovered - else -> colors.default - } - - val fillColor by animateColorAsState( - menuColor.fillColor, - animationSpec = tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + registerHoveredMenuItem(actualInteraction) {} + ListItem( + selected = selected, + selectionType = selectionType, + onSelectedChanged = onSelectedChanged, + icon = icon, + text = text, + modifier = modifier, + training = training, + interaction = interaction, + enabled = enabled, + colors = colors ) +} - val contentColor by animateColorAsState( - menuColor.contentColor, - animationSpec = tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) - ) +@Composable +fun MenuFlyoutScope.MenuFlyoutItem( + onClick: () -> Unit, + text: @Composable () -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + training: (@Composable () -> Unit)? = null, + interaction: MutableInteractionSource? = null, + enabled: Boolean = true, + colors: VisualStateScheme = ListItemDefaults.defaultListItemColors() +) { + val actualInteraction = interaction ?: remember { MutableInteractionSource() } registerHoveredMenuItem(actualInteraction) {} - Layer( - modifier = modifier - .padding(horizontal = 4.dp, vertical = 2.dp).defaultMinSize( - minWidth = 108.dp, - minHeight = 28.dp - ).fillMaxWidth(), - shape = RoundedCornerShape(4.dp), - border = BorderStroke(1.dp, menuColor.borderBrush), - color = fillColor, - contentColor = contentColor, - outsideBorder = true, - cornerRadius = 4.dp - ) { - Row( - modifier = Modifier - .clickable( - onClick = onClick, - interactionSource = actualInteraction, - indication = null, - enabled = enabled - ) - .padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.Start), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier.then( - if (paddingIcon) { - Modifier.defaultMinSize(minWidth = 16.dp) - } else { - Modifier - } - ), - contentAlignment = Alignment.Center - ) { - icon() - } - text() - CompositionLocalProvider( - LocalContentColor provides menuColor.trainingColor, - LocalContentAlpha provides menuColor.trainingColor.alpha - ) { - training?.invoke() - } - } - } + ListItem( + onClick = onClick, + icon = icon, + text = text, + modifier = modifier, + training = training, + interaction = interaction, + enabled = enabled, + colors = colors + ) } @Composable fun MenuFlyoutScope.MenuFlyoutItem( items: @Composable MenuFlyoutScope.() -> Unit, - icon: (@Composable () -> Unit), text: @Composable () -> Unit, modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, interaction: MutableInteractionSource? = null, enabled: Boolean = true, - colors: MenuColors = menuColors(), + colors: VisualStateScheme = ListItemDefaults.defaultListItemColors(), ) { val paddingTop = with(LocalDensity.current) { flyoutPopPaddingFixShadowRender.roundToPx() } BasicFlyoutContainer( @@ -247,13 +226,7 @@ fun MenuFlyoutScope.MenuFlyoutItem( onClick = { isFlyoutVisible = !isFlyoutVisible }, icon = icon, text = text, - training = { - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - modifier = Modifier.size(12.dp).offset(x = 6.dp) - ) - }, + training = { ListItemDefaults.CascadingIcon() }, modifier = modifier, interaction = interactionSource, enabled = enabled, @@ -266,7 +239,6 @@ fun MenuFlyoutScope.MenuFlyoutItem( } } - private class MenuFlyoutScopeImpl : MenuFlyoutScope { var latestHoveredItem: String? by mutableStateOf(null) @@ -295,54 +267,23 @@ private class MenuFlyoutScopeImpl : MenuFlyoutScope { } } -@Immutable -data class MenuColors( - val default: MenuColor, - val hovered: MenuColor, - val pressed: MenuColor, - val disabled: MenuColor +@Deprecated( + message = "use ListItemColorScheme instead", + replaceWith = ReplaceWith( + expression = "ListItemColorScheme", + imports = arrayOf("com.konyaco.fluent.component.ListItemColorScheme") + ) ) +typealias MenuColors = ListItemColorScheme -@Immutable -data class MenuColor( - val fillColor: Color, - val contentColor: Color, - val trainingColor: Color, - val borderBrush: Brush +@Deprecated( + message = "use ListItemColor instead", + replaceWith = ReplaceWith( + "ListItemColor", + imports = arrayOf("com.konyaco.fluent.component.ListItemColor") + ) ) - -@Composable -private fun menuColors(): MenuColors { - val colors = FluentTheme.colors - return remember(colors) { - MenuColors( - default = MenuColor( - colors.subtleFill.transparent, - colors.text.text.primary, - colors.text.text.primary.copy(0.6f), - SolidColor(Color.Transparent) - ), - hovered = MenuColor( - colors.subtleFill.secondary, - colors.text.text.primary, - colors.text.text.primary.copy(0.6f), - SolidColor(Color.Transparent) - ), - pressed = MenuColor( - colors.subtleFill.tertiary, - colors.text.text.secondary, - colors.text.text.secondary.copy(0.6f), - SolidColor(Color.Transparent) - ), - disabled = MenuColor( - colors.subtleFill.disabled, - colors.text.text.disabled, - colors.text.text.disabled, - SolidColor(Color.Transparent) - ), - ) - } -} +typealias MenuColor = ListItemColor interface MenuFlyoutScope { @@ -353,6 +294,14 @@ interface MenuFlyoutScope { ) } +interface MenuFlyoutContainerScope : MenuFlyoutScope, FlyoutContainerScope + +private class MenuFlyoutContainerScopeImpl( + flyoutScope: FlyoutContainerScope, + menuFlyoutScope: MenuFlyoutScope +) : MenuFlyoutContainerScope, FlyoutContainerScope by flyoutScope, + MenuFlyoutScope by menuFlyoutScope + private fun defaultMenuFlyoutEnterPlacementAnimation( placement: FlyoutPlacement, paddingTop: Int @@ -410,7 +359,7 @@ private class SubMenuFlyoutPositionProvider( layoutDirection: LayoutDirection, popupContentSize: Size ): Pair { - return calculateHorizontalPlacement( + return calculatePlacementByHorizontal( anchorBounds, windowSize, layoutDirection, diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Popup.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Popup.kt new file mode 100644 index 00000000..b16b4453 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Popup.kt @@ -0,0 +1,17 @@ +package com.konyaco.fluent.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties + +//Remove when compose common source support key event parameter +@Composable +internal expect fun Popup( + popupPositionProvider: PopupPositionProvider, + onDismissRequest: (() -> Unit)? = null, + properties: PopupProperties = PopupProperties(), + onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null, + onKeyEvent: ((KeyEvent) -> Boolean)? = null, + content: @Composable () -> Unit +) \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/RadioButton.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/RadioButton.kt index 78468022..2355501a 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/RadioButton.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/RadioButton.kt @@ -6,20 +6,35 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.konyaco.fluent.FluentTheme import com.konyaco.fluent.animation.FluentDuration import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.BackgroundSizing import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.VisualStateScheme +import com.konyaco.fluent.scheme.collectVisualState +import kotlin.math.roundToInt @Composable fun RadioButton( @@ -28,12 +43,15 @@ fun RadioButton( modifier: Modifier = Modifier, label: String? = null, enabled: Boolean = true, + styles: VisualStateScheme = if(selected) { + RadioButtonDefaults.selectedRadioButtonStyle() + } else { + RadioButtonDefaults.defaultRadioButtonStyle() + }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { // TODO: Extract same logic - val hovered by interactionSource.collectIsHoveredAsState() - val pressed by interactionSource.collectIsPressedAsState() - + val style = styles.schemeFor(interactionSource.collectVisualState( !enabled)) Row(modifier.then( if (label != null) Modifier.defaultMinSize(minWidth = 120.dp) else Modifier @@ -41,60 +59,36 @@ fun RadioButton( onClick?.invoke() }) { val fillColor by animateColorAsState( - if (selected) when { - !enabled -> FluentTheme.colors.fillAccent.disabled - pressed -> FluentTheme.colors.fillAccent.tertiary - hovered -> FluentTheme.colors.fillAccent.secondary - else -> FluentTheme.colors.fillAccent.default - } else when { - !enabled -> FluentTheme.colors.controlAlt.disabled - pressed -> FluentTheme.colors.controlAlt.quaternary - hovered -> FluentTheme.colors.controlAlt.tertiary - else -> FluentTheme.colors.controlAlt.secondary - }, + style.fillColor, tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) ) Layer( modifier = Modifier.size(20.dp), shape = CircleShape, color = fillColor, - outsideBorder = true, border = BorderStroke( 1.dp, - color = if (selected) when { - !enabled -> FluentTheme.colors.fillAccent.disabled - else -> FluentTheme.colors.fillAccent.default - } else when { - !enabled || pressed -> FluentTheme.colors.stroke.controlStrong.disabled - else -> FluentTheme.colors.stroke.controlStrong.default - } + style.borderColor ), - circular = true + backgroundSizing = BackgroundSizing.InnerBorderEdge ) { - Box(contentAlignment = Alignment.Center) { + Box(contentAlignment = FixedCenterAlignment) { // Bullet, Only displays when selected, or is pressed val size by animateDpAsState( - if (selected) when { - pressed -> 6.dp - hovered -> 10.dp - else -> 8.dp - } else when { - pressed -> 10.dp - else -> 0.dp - }, + style.dotSize, tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) ) // Inner Layer( modifier = Modifier.size(if (size == 0.dp || !selected) size else size + 2.dp), // TODO: Remove this 2dp if outside border is provided - color = FluentTheme.colors.text.onAccent.primary, + shape = CircleShape, + color = style.dotColor, border = if (selected) BorderStroke( 1.dp, - FluentTheme.colors.borders.circle + style.dotBorderBrush ) else null, - shape = CircleShape, - outsideBorder = true, + backgroundSizing = BackgroundSizing.InnerBorderEdge, content = {} ) } @@ -105,10 +99,92 @@ fun RadioButton( modifier = Modifier.offset(y = (-1).dp), text = it, style = FluentTheme.typography.body.copy( - color = if (enabled) FluentTheme.colors.text.text.primary - else FluentTheme.colors.text.text.disabled + color = style.labelColor ) ) } } +} + +typealias RadioButtonStyleScheme = PentaVisualScheme + +@Immutable +data class RadioButtonStyle( + val fillColor: Color, + val borderColor: Color, + val labelColor: Color, + val dotSize: Dp, + val dotColor: Color, + val dotBorderBrush: Brush +) + +object RadioButtonDefaults { + + @Stable + @Composable + fun defaultRadioButtonStyle( + default: RadioButtonStyle = RadioButtonStyle( + fillColor = FluentTheme.colors.controlAlt.secondary, + borderColor = FluentTheme.colors.stroke.controlStrong.default, + labelColor = FluentTheme.colors.text.text.primary, + dotSize = 0.dp, + dotColor = FluentTheme.colors.text.onAccent.primary, + dotBorderBrush = FluentTheme.colors.borders.circle + ), + hovered: RadioButtonStyle = default.copy( + fillColor = FluentTheme.colors.controlAlt.tertiary, + ), + pressed: RadioButtonStyle = default.copy( + fillColor = FluentTheme.colors.controlAlt.quaternary, + borderColor = FluentTheme.colors.stroke.controlStrong.disabled, + dotSize = 10.dp + ), + disabled: RadioButtonStyle = default.copy( + fillColor = FluentTheme.colors.controlAlt.disabled, + borderColor = FluentTheme.colors.stroke.controlStrong.disabled, + labelColor = FluentTheme.colors.text.text.disabled + ) + ) = RadioButtonStyleScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) + + @Stable + @Composable + fun selectedRadioButtonStyle( + default: RadioButtonStyle = RadioButtonStyle( + fillColor = FluentTheme.colors.fillAccent.default, + borderColor = FluentTheme.colors.fillAccent.default, + labelColor = FluentTheme.colors.text.text.primary, + dotSize = 8.dp, + dotColor = FluentTheme.colors.text.onAccent.primary, + dotBorderBrush = FluentTheme.colors.borders.circle + ), + hovered: RadioButtonStyle = default.copy( + fillColor = FluentTheme.colors.fillAccent.secondary, + dotSize = 10.dp, + ), + pressed: RadioButtonStyle = default.copy( + fillColor = FluentTheme.colors.fillAccent.tertiary, + dotSize = 6.dp + ), + disabled: RadioButtonStyle = default.copy( + fillColor = FluentTheme.colors.fillAccent.disabled, + borderColor = FluentTheme.colors.fillAccent.disabled, + labelColor = FluentTheme.colors.text.text.disabled + ) + ) = RadioButtonStyleScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) +} + +private val FixedCenterAlignment = Alignment { size, space, _ -> + val centerX = (space.width - size.width).toFloat() / 2f + val centerY = (space.height - size.height).toFloat() / 2f + IntOffset(x = centerX.toInt(), y = centerY.roundToInt()) } \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/RatingControl.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/RatingControl.kt new file mode 100644 index 00000000..0ad573d8 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/RatingControl.kt @@ -0,0 +1,373 @@ +package com.konyaco.fluent.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.LocalContentColor +import com.konyaco.fluent.ProvideTextStyle +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.filled.Star +import com.konyaco.fluent.icons.regular.Star +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.VisualStateScheme +import com.konyaco.fluent.scheme.collectVisualState +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.isActive + +/** + * @param width: star width + * @param placeholderValue: the placeholder value will display when value is 0f + * @param maxRating: star count + * @param isClearEnabled: click same star value will clear value. + * @param caption: addition info for rating control + * @param stepValue: if true, [onValueChanged] will set value as int, otherwise [onValueChanged] will set value as float + * @param isReadOnly: if true, user can't set [value] by click. + */ +@Composable +fun RatingControl( + value: Float, + onValueChanged: (Float) -> Unit, + modifier: Modifier = Modifier, + colors: VisualStateScheme = RatingControlDefaults.colors(), + width: Dp = 20.dp, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + placeholderValue: Float = 0f, + maxRating: Int = 5, + caption: @Composable () -> Unit = {}, + stepValue: Boolean = true, + isReadOnly: Boolean = false, + isClearEnabled: Boolean = false, + disabled: Boolean = false +) { + val valueRange = 0f..maxRating.toFloat() + require(value in valueRange) { "value is invalid" } + val isHovered = interactionSource.collectIsHoveredAsState() + val itemPositions = remember(maxRating) { mutableStateListOf(*Array(maxRating) { Rect.Zero }) } + val valueState by rememberUpdatedState { value } + val placeholderValueState by rememberUpdatedState { placeholderValue } + val displayPlaceholder = remember { + derivedStateOf { valueState() == 0f && placeholderValueState() > 0f && !isHovered.value } + } + val shapeFraction by remember { + derivedStateOf { + parseValueToFraction(itemPositions, if (displayPlaceholder.value) placeholderValueState() else valueState()) + } + } + val color = colors.schemeFor(interactionSource.collectVisualState(disabled)) + val hoveredOffset = remember { mutableStateOf(null) } + /** collect pointer release offset */ + LaunchedEffect(interactionSource, value) { + interactionSource.interactions.filterIsInstance() + .collectLatest { + when (it) { + is PressInteraction.Release -> { + val offset = hoveredOffset.value ?: it.press.pressPosition + val targetValue = getValueByOffset(stepValue, offset, itemPositions) + if (isClearEnabled && value == targetValue) { + onValueChanged(0f) + } else { + println(targetValue) + onValueChanged(targetValue) + } + } + } + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = modifier.hoverable(interactionSource, !disabled) + .pointerInput(isReadOnly, disabled) { + /** detect hovered state pointer offset */ + val currentContext = currentCoroutineContext() + if (!isReadOnly && !disabled) { + awaitPointerEventScope { + while (currentContext.isActive) { + val event = awaitPointerEvent(PointerEventPass.Main) + if (event.type == PointerEventType.Move) { + hoveredOffset.value = event.changes.first().position + } else if (event.type == PointerEventType.Exit) { + hoveredOffset.value = null + } + } + } + } else if (hoveredOffset.value != null) { + hoveredOffset.value = null + } + } + .clickable( + enabled = !disabled && !isReadOnly, + interactionSource = interactionSource, + onClick = {}, + indication = null + ) + ) { + drawStar( + color = color, + width = width, + maxRating = maxRating, + selected = true, + isHovered = isHovered.value, + onItemPositioned = { index, position -> itemPositions[index] = position }, + value = valueState, + displayPlaceholder = displayPlaceholder.value, + modifier = Modifier.graphicsLayer { + clip = true + val currentHoveredOffset = hoveredOffset.value + shape = if (currentHoveredOffset != null) { + val hoveredValue = getValueByOffset(stepValue, currentHoveredOffset, itemPositions) + RatingStarClipShape(true, parseValueToFraction(itemPositions, hoveredValue)) + } else { + RatingStarClipShape(true, shapeFraction) + } + } + ) + drawStar( + color = color, + width = width, + maxRating = maxRating, + selected = false, + isHovered = isHovered.value, + value = valueState, + displayPlaceholder = displayPlaceholder.value, + modifier = Modifier.graphicsLayer { + clip = true + val currentHoveredOffset = hoveredOffset.value + shape = if (currentHoveredOffset != null) { + val hoveredValue = getValueByOffset(stepValue, currentHoveredOffset, itemPositions) + RatingStarClipShape(false, parseValueToFraction(itemPositions, hoveredValue)) + } else { + RatingStarClipShape(false, shapeFraction) + } + }) + } + ProvideTextStyle( + FluentTheme.typography.caption.copy(color.captionColor) + ) { + CompositionLocalProvider( + LocalContentColor provides color.captionColor + ) { + caption() + } + } + + } +} + +typealias RatingControlColorScheme = PentaVisualScheme + +@Immutable +data class RatingControlColor( + val color: Color, + val selectedColor: Color, + val captionColor: Color, + val placeholderColor: Color +) + +object RatingControlDefaults { + + @Composable + @Stable + fun colors( + default: RatingControlColor = RatingControlColor( + color = FluentTheme.colors.text.text.secondary, + selectedColor = FluentTheme.colors.fillAccent.default, + placeholderColor = FluentTheme.colors.text.text.primary, + captionColor = FluentTheme.colors.text.text.secondary + ), + hovered: RatingControlColor = default.copy( + selectedColor = FluentTheme.colors.fillAccent.default, + placeholderColor = FluentTheme.colors.controlAlt.tertiary, + ), + pressed: RatingControlColor = default, + disabled: RatingControlColor = default.copy( + selectedColor = FluentTheme.colors.text.text.secondary + ) + ) = RatingControlColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) +} + +/** parse value to rating control clip percent */ +private fun parseValueToFraction(itemPositions: List, value: Float): Float { + val currentValueInt = value.toInt() + val currentValueFloat = value - currentValueInt + val lastRect = itemPositions.last() + return if (lastRect.right > 0) { + if (currentValueFloat == 0f && currentValueInt > 0) { + itemPositions[currentValueInt - 1].right / lastRect.right + } else { + val item = itemPositions[currentValueInt] + (item.left + item.width * currentValueFloat) / lastRect.right + } + } else { + 0f + } +} + +/** parse hovered offset to star value */ +private fun getValueByOffset(stepValue: Boolean = true, offset: Offset, itemPositions: List): Float { + val lastIndex = itemPositions.lastIndex + val offsetX = offset.x + for (index in lastIndex downTo 0) { + val nextRect = itemPositions.getOrNull(index + 1) + val rect = itemPositions[index] + when { + index == lastIndex && rect.right <= offsetX -> return lastIndex + 1f + offsetX >= rect.left && offsetX <= (nextRect?.right ?: rect.right) -> return if (stepValue) { + index + 1f + } else { + index + ((offsetX - rect.left) / rect.width).coerceAtMost(1f) + } + } + } + return 0f +} + +@Composable +private fun drawStar( + color: RatingControlColor, + width: Dp, + maxRating: Int, + selected: Boolean, + isHovered: Boolean, + displayPlaceholder: Boolean, + modifier: Modifier = Modifier, + onItemPositioned: (index: Int, bounds: Rect) -> Unit = { _, _ -> }, + value: () -> Float = { 0.0f } +) { + Row(horizontalArrangement = Arrangement.spacedBy(ratingSpacing), modifier = modifier) { + val hasValue = value() != 0f + val (icon, iconColor) = when { + selected -> Icons.Filled.Star to when { + (!hasValue && isHovered) || displayPlaceholder -> color.placeholderColor + else -> color.selectedColor + } + + else -> Icons.Regular.Star to color.color + } + repeat(maxRating) { index -> + Box( + modifier = Modifier.onGloballyPositioned { + onItemPositioned(index, it.boundsInParent()) + } + ) { + //TODO Update star icon + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(width) + ) + if (isHovered && !hasValue && selected) { + Icon( + imageVector = Icons.Regular.Star, + contentDescription = null, + tint = color.color, + modifier = Modifier.size(width) + ) + } + } + + + } + } +} + +@Stable +private class RatingStarClipShape( + private val isStart: Boolean, + private val fraction: Float +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val isRtl = layoutDirection == LayoutDirection.Rtl + + return Outline.Rectangle( + if ((!isRtl && isStart) || isRtl) { + Rect( + Offset.Zero, + Size( + size.width * if (!isRtl) { + fraction + } else { + 1 - fraction + }, + size.height + ) + ) + } else { + Rect( + Offset( + size.width * fraction, + 0f + ), + Size( + size.width * (1 - fraction), + size.height + ) + ) + } + ) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + val otherShape = other as? RatingStarClipShape ?: return false + return otherShape.isStart == isStart && otherShape.fraction == fraction + } + + override fun hashCode(): Int { + return 31 * fraction.hashCode() + isStart.hashCode() + } +} + +private val ratingSpacing = 4.dp \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/SideNav.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/SideNav.kt index 512063ab..db1ee3fe 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/SideNav.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/SideNav.kt @@ -2,7 +2,13 @@ package com.konyaco.fluent.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.* +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -10,20 +16,46 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.* +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -31,11 +63,14 @@ import com.konyaco.fluent.FluentTheme import com.konyaco.fluent.LocalTextStyle import com.konyaco.fluent.animation.FluentDuration import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.BackgroundSizing import com.konyaco.fluent.background.Layer import com.konyaco.fluent.icons.Icons import com.konyaco.fluent.icons.regular.ChevronDown import com.konyaco.fluent.icons.regular.Navigation import com.konyaco.fluent.icons.regular.Search +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.collectVisualState import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -154,21 +189,21 @@ fun SideNavItem( onClick: (Boolean) -> Unit, modifier: Modifier = Modifier, expandItems: Boolean = false, + colors: NavigationItemColorScheme = if (selected) { + NavigationDefaults.selectedSideNavigationItemColors() + } else { + NavigationDefaults.defaultSideNavigationItemColors() + }, icon: @Composable (() -> Unit)? = null, items: @Composable (ColumnScope.() -> Unit)? = null, + indicator: @Composable IndicatorScope.() -> Unit = { + NavigationDefaults.VerticalIndicator(modifier = Modifier.indicatorOffset { selected }) + }, content: @Composable RowScope.() -> Unit ) { val interaction = remember { MutableInteractionSource() } - val hovered by interaction.collectIsHoveredAsState() - val pressed by interaction.collectIsPressedAsState() - - val color = when { - selected && hovered -> FluentTheme.colors.subtleFill.tertiary - selected -> FluentTheme.colors.subtleFill.secondary - pressed -> FluentTheme.colors.subtleFill.tertiary - hovered -> FluentTheme.colors.subtleFill.secondary - else -> FluentTheme.colors.subtleFill.transparent - } + //TODO Enabled + val color = colors.schemeFor(interaction.collectVisualState(false)) var currentPosition by remember { mutableStateOf(0f) } @@ -187,14 +222,13 @@ fun SideNavItem( val navigationLevelPadding = 28.dp * LocalNavigationLevel.current Layer( modifier = Modifier.fillMaxWidth().height(36.dp), - shape = RoundedCornerShape(4.dp), + shape = RoundedCornerShape(size = 4.dp), color = animateColorAsState( - color, tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + color.fillColor, tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) ).value, - contentColor = FluentTheme.colors.text.text.primary, - outsideBorder = false, - cornerRadius = 4.dp, - border = null + contentColor = color.contentColor, + border = null, + backgroundSizing = BackgroundSizing.OuterBorderEdge ) { Box( Modifier.clickable( @@ -249,7 +283,9 @@ fun SideNavItem( } } } - Indicator(Modifier.align(Alignment.CenterStart).padding(start = navigationLevelPadding), selected) + Box(modifier = Modifier.align(Alignment.CenterStart).padding(start = navigationLevelPadding)) { + SideNavigationIndicatorScope.indicator() + } } if (items != null) { @@ -279,10 +315,118 @@ fun SideNavItem( } } +typealias NavigationItemColorScheme = PentaVisualScheme + +@Immutable +data class NavigationItemColor( + val fillColor: Color, + val contentColor: Color +) + +//TODO Common Defaults for SideNavigation and TopNavigation +object NavigationDefaults { + + @Composable + @Stable + fun defaultSideNavigationItemColors( + default: NavigationItemColor = NavigationItemColor( + fillColor = FluentTheme.colors.subtleFill.transparent, + contentColor = FluentTheme.colors.text.text.primary + ), + hovered: NavigationItemColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.secondary + ), + pressed: NavigationItemColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.tertiary + ), + //TODO Disabled style + disabled: NavigationItemColor = default + ) = NavigationItemColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) + + @Composable + @Stable + fun selectedSideNavigationItemColors( + default: NavigationItemColor = NavigationItemColor( + fillColor = FluentTheme.colors.subtleFill.secondary, + contentColor = FluentTheme.colors.text.text.primary + ), + hovered: NavigationItemColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.tertiary + ), + pressed: NavigationItemColor = default.copy( + fillColor = FluentTheme.colors.subtleFill.tertiary + ), + //TODO Disabled style + disabled: NavigationItemColor = default + ) = NavigationItemColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) + + @Composable + fun VerticalIndicator( + modifier: Modifier = Modifier, + color: Color = FluentTheme.colors.fillAccent.default, + shape: Shape = CircleShape, + thickness: Dp = 3.dp + ) { + Box(modifier.width(thickness).background(color, shape)) + } + + //TODO TopNavigation + @Composable + fun HorizontalIndicator( + modifier: Modifier = Modifier, + color: Color = FluentTheme.colors.fillAccent.default, + shape: Shape = CircleShape, + thickness: Dp = 3.dp + ) { + Box(modifier.height(thickness).background(color, shape)) + } + +} + +interface IndicatorScope { + + @Composable + fun Modifier.indicatorOffset(visible: () -> Boolean): Modifier +} + interface AutoSuggestionBoxScope { fun Modifier.focusHandle(): Modifier } +private object SideNavigationIndicatorScope: IndicatorScope { + + @Composable + override fun Modifier.indicatorOffset(visible: () -> Boolean): Modifier { + val display by rememberUpdatedState(visible) + val selectionState = LocalSelectedItemPosition.current + val indicatorState = remember { + MutableTransitionState(display()) + } + indicatorState.targetState = display() + val animationModifier = if (selectionState != null) { + Modifier.indicatorOffsetAnimation(16.dp, indicatorState, selectionState) + } else { + val height by updateTransition(display()).animateDp(transitionSpec = { + if (targetState) tween(FluentDuration.ShortDuration, easing = FluentEasing.FastInvokeEasing) + else tween(FluentDuration.QuickDuration, easing = FluentEasing.SoftDismissEasing) + }, targetValueByState = { if (it) 16.dp else 0.dp }) + Modifier.height(height) + } + return then(animationModifier) + } +} +//TODO TopNavigationIndicatorScope + internal class AutoSuggestionBoxScopeImpl( private val focusRequest: FocusRequester ) : AutoSuggestionBoxScope { @@ -292,7 +436,8 @@ internal class AutoSuggestionBoxScopeImpl( @Composable fun NavigationItemSeparator( isVertical: Boolean = false, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + color: Color = FluentTheme.colors.stroke.surface.default.copy(0.2f) ) { val sizeModifier = if (!isVertical) { Modifier.fillMaxWidth().height(1.dp) @@ -303,29 +448,10 @@ fun NavigationItemSeparator( Modifier .then(modifier) .then(sizeModifier) - .background(FluentTheme.colors.stroke.surface.default.copy(0.2f)) + .background(color) ) } -@Composable -private fun Indicator(modifier: Modifier, display: Boolean) { - val selectionState = LocalSelectedItemPosition.current - val indicatorState = remember { - MutableTransitionState(display) - } - indicatorState.targetState = display - val animationModifier = if (selectionState != null) { - Modifier.indicatorOffsetAnimation(16.dp, indicatorState, selectionState) - } else { - val height by updateTransition(display).animateDp(transitionSpec = { - if (targetState) tween(FluentDuration.ShortDuration, easing = FluentEasing.FastInvokeEasing) - else tween(FluentDuration.QuickDuration, easing = FluentEasing.SoftDismissEasing) - }, targetValueByState = { if (it) 16.dp else 0.dp }) - Modifier.height(height) - } - Box(modifier.width(3.dp).then(animationModifier).background(FluentTheme.colors.fillAccent.default, CircleShape)) -} - @Composable private fun Modifier.indicatorOffsetAnimation( size: Dp, @@ -387,9 +513,9 @@ private fun Modifier.indicatorOffsetAnimation( } } val placeable = if (isVertical) { - measurable.measure(Constraints.fixed(constraints.maxWidth, currentSize.roundToInt().coerceAtLeast(0))) + measurable.measure(Constraints.fixedHeight(currentSize.roundToInt().coerceAtLeast(0))) } else { - measurable.measure(Constraints.fixed(currentSize.roundToInt().coerceAtLeast(0), constraints.maxHeight)) + measurable.measure(Constraints.fixedWidth(currentSize.roundToInt().coerceAtLeast(0))) } layout( diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Slider.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Slider.kt index d832c6b7..d2655f3c 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Slider.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Slider.kt @@ -33,6 +33,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp @@ -42,6 +44,7 @@ import androidx.compose.ui.unit.times import com.konyaco.fluent.FluentTheme import com.konyaco.fluent.animation.FluentDuration import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.BackgroundSizing import com.konyaco.fluent.background.Layer @Composable @@ -54,6 +57,9 @@ fun Slider( steps: Int = 0, // TODO onValueChangeFinished: (() -> Unit)? = null, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + rail: @Composable () -> Unit = { SliderDefaults.Rail() }, + track: @Composable (progress: Float, width: Dp) -> Unit = { fraction, width -> SliderDefaults.Track(fraction, width) }, + thumb: @Composable (progress: Float, width: Dp, dragging: Boolean) -> Unit = { fraction, width, dragging -> SliderDefaults.Thumb(fraction, width, dragging) }, ) { val progress = valueToFraction(value, valueRange.start, valueRange.endInclusive) Slider( @@ -64,7 +70,10 @@ fun Slider( }, enabled = enabled, onValueChangeFinished = onValueChangeFinished, - interactionSource = interactionSource + interactionSource = interactionSource, + rail = rail, + track = track, + thumb = thumb ) } @@ -76,6 +85,9 @@ private fun Slider( enabled: Boolean = true, // TODO onValueChangeFinished: (() -> Unit)? = null, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + rail: @Composable () -> Unit = { SliderDefaults.Rail() }, + track: @Composable (progress: Float, width: Dp) -> Unit = { fraction, width -> SliderDefaults.Track(fraction, width) }, + thumb: @Composable (progress: Float, width: Dp, dragging: Boolean) -> Unit = { fraction, width, dragging -> SliderDefaults.Thumb(fraction, width, dragging) }, ) { // TODO: Refactor this component val currentOnProgressChange by rememberUpdatedState(onProgressChange) @@ -122,9 +134,9 @@ private fun Slider( } }, contentAlignment = Alignment.CenterStart ) { - Rail() - Track(progress, width) - Thumb(width, progress, dragging) + rail() + track(progress, width) + thumb(progress, width, dragging) } } } @@ -145,69 +157,88 @@ private fun calcThumbOffset( return (maxWidth - thumbSize) * fraction - padding } -@Composable -private fun Rail() { - // Rail - Layer(modifier = Modifier.fillMaxWidth().requiredHeight(4.dp), - shape = CircleShape, - color = FluentTheme.colors.controlStrong.default, - border = BorderStroke( +object SliderDefaults { + + @Composable + fun Track( + fraction: Float, + maxWidth: Dp, + modifier: Modifier = Modifier, + color: Color = FluentTheme.colors.fillAccent.default, + shape: Shape = CircleShape + ) { + // Track + val width = ThumbRadiusWithBorder + (fraction * (maxWidth - ThumbSizeWithBorder)) + Box( + modifier.width(width) + .requiredHeight(4.dp) + .background(color, shape) + ) + } + + @Composable + fun Rail( + modifier: Modifier = Modifier, + color: Color = FluentTheme.colors.controlStrong.default, + border: BorderStroke? = BorderStroke( 1.dp, if (FluentTheme.colors.darkMode) FluentTheme.colors.stroke.controlStrong.default else FluentTheme.colors.controlStrong.default ), - outsideBorder = true, - content = {} - ) -} - -@Composable -private fun Track( - fraction: Float, - maxWidth: Dp -) { - // Track - val width = ThumbRadiusWithBorder + (fraction * (maxWidth - ThumbSizeWithBorder)) - Box( - Modifier.width(width) - .requiredHeight(4.dp) - .background(FluentTheme.colors.fillAccent.default, CircleShape) - ) -} + shape: Shape = CircleShape + ) { + // Rail + Layer( + modifier = modifier.fillMaxWidth().requiredHeight(4.dp), + shape = shape, + color = color, + border = border, + backgroundSizing = BackgroundSizing.InnerBorderEdge, + content = {} + ) + } -@Composable -private fun Thumb( - maxWidth: Dp, fraction: Float, dragging: Boolean, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } -) { - // Thumb - val thumbOffset by rememberUpdatedState(calcThumbOffset(maxWidth, ThumbSize, 1.dp, fraction)) + @Composable + fun Thumb( + fraction: Float, + maxWidth: Dp, + dragging: Boolean = false, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = CircleShape, + border: BorderStroke? = BorderStroke(1.dp, FluentTheme.colors.borders.circle), + ringColor: Color = FluentTheme.colors.controlSolid.default, + color: Color = FluentTheme.colors.fillAccent.default + ) { + // Thumb + val thumbOffset by rememberUpdatedState(calcThumbOffset(maxWidth, ThumbSize, 1.dp, fraction)) - val hovered by interactionSource.collectIsHoveredAsState() - val pressed by interactionSource.collectIsPressedAsState() + val hovered by interactionSource.collectIsHoveredAsState() + val pressed by interactionSource.collectIsPressedAsState() - Layer( - modifier = Modifier.offset { IntOffset(x = thumbOffset.roundToPx(), y = 0) } - .size(ThumbSizeWithBorder) - .clickable(interactionSource, null, onClick = {}), - shape = CircleShape, - border = BorderStroke(1.dp, FluentTheme.colors.borders.circle), - color = FluentTheme.colors.controlSolid.default, - outsideBorder = true - ) { - Box(contentAlignment = Alignment.Center) { - // Inner Thumb - Box( - Modifier.size( - animateDpAsState( - when { - pressed || dragging -> InnerThumbPressedSize - hovered -> InnerThumbHoverSize - else -> InnerThumbSize - }, - tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) - ).value - ).background(FluentTheme.colors.fillAccent.default, CircleShape) - ) + Layer( + modifier = modifier.offset { IntOffset(x = thumbOffset.roundToPx(), y = 0) } + .size(ThumbSizeWithBorder) + .clickable(interactionSource, null, onClick = {}), + shape = shape, + color = ringColor, + border = border, + backgroundSizing = BackgroundSizing.InnerBorderEdge + ) { + Box(contentAlignment = Alignment.Center) { + // Inner Thumb + Box( + Modifier.size( + animateDpAsState( + when { + pressed || dragging -> InnerThumbPressedSize + hovered -> InnerThumbHoverSize + else -> InnerThumbSize + }, + tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + ).value + ).background(color, shape) + ) + } } } } diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Switcher.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Switcher.kt index 0833f993..eddb1e47 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Switcher.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Switcher.kt @@ -9,11 +9,16 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -22,14 +27,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.konyaco.fluent.FluentTheme import com.konyaco.fluent.animation.FluentDuration import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.collectVisualState @Composable fun Switcher( @@ -38,15 +47,24 @@ fun Switcher( text: String? = null, textBefore: Boolean = false, enabled: Boolean = true, + styles: SwitcherStyleScheme = if (checked) { + SwitcherDefaults.selectedSwitcherStyle() + } else { + SwitcherDefaults.defaultSwitcherStyle() + }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { // TODO: Draggable // TODO: Extract same logic - val hovered by interactionSource.collectIsHoveredAsState() - val pressed by interactionSource.collectIsPressedAsState() val transition = updateTransition(checked) + val style = styles.schemeFor(interactionSource.collectVisualState(!enabled)) Row( - modifier = Modifier.clickable(indication = null, interactionSource = interactionSource, role = Role.Button) { + modifier = Modifier.clickable( + indication = null, + interactionSource = interactionSource, + role = Role.Button, + enabled = enabled + ) { onCheckStateChange(!checked) }, verticalAlignment = Alignment.CenterVertically @@ -56,59 +74,32 @@ fun Switcher( Text( modifier = Modifier.offset(y = (-1).dp), text = it, - color = if (enabled) FluentTheme.colors.text.text.primary - else FluentTheme.colors.text.text.disabled + color = style.labelColor ) Spacer(Modifier.width(12.dp)) } } - val colors = FluentTheme.colors val fillColor by animateColorAsState( - if (checked) when { - !enabled -> colors.fillAccent.disabled - pressed -> colors.fillAccent.tertiary - hovered -> colors.fillAccent.secondary - else -> colors.fillAccent.default - } else when { - !enabled -> colors.controlAlt.disabled - pressed -> colors.controlAlt.quaternary - hovered -> colors.controlAlt.tertiary - else -> colors.controlAlt.secondary - }, + style.fillColor, tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) ) Box( modifier = Modifier.size(40.dp, 20.dp) - .border( - 1.dp, if (checked) when { - !enabled -> FluentTheme.colors.fillAccent.disabled - else -> Color.Transparent - } else when { - !enabled -> FluentTheme.colors.controlStrong.disabled - else -> FluentTheme.colors.controlStrong.default - }, CircleShape - ) + .border(1.dp, style.borderBrush, CircleShape) .clip(CircleShape) .background(fillColor) .padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart ) { val height by animateDpAsState( - when { - pressed || hovered -> 14.dp - else -> 12.dp - }, + style.controlSize.height, tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) ) val width by animateDpAsState( - when { - pressed -> 17.dp - hovered -> 14.dp - else -> 12.dp - }, + style.controlSize.width, tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) ) @@ -154,23 +145,84 @@ fun Switcher( modifier = Modifier.offset(y = (-1).dp), text = it, style = FluentTheme.typography.body, - color = if (enabled) FluentTheme.colors.text.text.primary - else FluentTheme.colors.text.text.disabled + color = style.labelColor ) } } } } -data class SwitcherColors( - val default: SwitcherColor, - val hovered: SwitcherColor, - val pressed: SwitcherColor, - val disabled: SwitcherColor -) +object SwitcherDefaults { + + @Stable + @Composable + fun defaultSwitcherStyle( + default: SwitcherStyle = SwitcherStyle( + fillColor = FluentTheme.colors.controlAlt.secondary, + labelColor = FluentTheme.colors.text.text.primary, + controlColor = FluentTheme.colors.text.text.secondary, + controlSize = DpSize(width = 12.dp, height = 12.dp), + borderBrush = SolidColor(FluentTheme.colors.controlStrong.default) + ), + hovered: SwitcherStyle = default.copy( + fillColor = FluentTheme.colors.controlAlt.tertiary, + controlSize = DpSize(width = 14.dp, height = 14.dp) + ), + pressed: SwitcherStyle = default.copy( + fillColor = FluentTheme.colors.controlAlt.quaternary, + controlSize = DpSize(width = 17.dp, height = 14.dp) + ), + disabled: SwitcherStyle = default.copy( + fillColor = FluentTheme.colors.controlAlt.disabled, + borderBrush = SolidColor(FluentTheme.colors.controlStrong.disabled), + controlColor = FluentTheme.colors.text.text.disabled, + labelColor = FluentTheme.colors.text.text.disabled + ) + ) = SwitcherStyleScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) + + @Stable + @Composable + fun selectedSwitcherStyle( + default: SwitcherStyle = SwitcherStyle( + fillColor = FluentTheme.colors.fillAccent.default, + labelColor = FluentTheme.colors.text.text.primary, + controlColor = FluentTheme.colors.text.onAccent.primary, + controlSize = DpSize(width = 12.dp, height = 12.dp), + borderBrush = SolidColor(Color.Transparent) + ), + hovered: SwitcherStyle = default.copy( + fillColor = FluentTheme.colors.fillAccent.secondary, + controlSize = DpSize(width = 14.dp, height = 14.dp) + ), + pressed: SwitcherStyle = default.copy( + fillColor = FluentTheme.colors.fillAccent.tertiary, + controlSize = DpSize(width = 17.dp, height = 14.dp) + ), + disabled: SwitcherStyle = default.copy( + fillColor = FluentTheme.colors.fillAccent.disabled, + borderBrush = SolidColor(FluentTheme.colors.fillAccent.disabled), + controlColor = FluentTheme.colors.text.onAccent.disabled, + labelColor = FluentTheme.colors.text.text.disabled + ) + ) = SwitcherStyleScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) +} + +typealias SwitcherStyleScheme = PentaVisualScheme -data class SwitcherColor( +data class SwitcherStyle( val fillColor: Color, - val textColor: Color, + val labelColor: Color, + val controlColor: Color, + val controlSize: DpSize, val borderBrush: Brush ) \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Text.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Text.kt index 511002ff..b0d3aa9f 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Text.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Text.kt @@ -31,7 +31,7 @@ fun Text( fontFamily: FontFamily? = null, letterSpacing: TextUnit = TextUnit.Unspecified, textDecoration: TextDecoration? = null, - textAlign: TextAlign? = null, + textAlign: TextAlign = TextAlign.Unspecified, lineHeight: TextUnit = TextUnit.Unspecified, overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, @@ -72,7 +72,7 @@ fun Text( fontFamily: FontFamily? = null, letterSpacing: TextUnit = TextUnit.Unspecified, textDecoration: TextDecoration? = null, - textAlign: TextAlign? = null, + textAlign: TextAlign = TextAlign.Unspecified, lineHeight: TextUnit = TextUnit.Unspecified, overflow: TextOverflow = TextOverflow.Clip, softWrap: Boolean = true, diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/TextField.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/TextField.kt index 3adc3818..2ddc1ff1 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/TextField.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/TextField.kt @@ -4,14 +4,21 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -21,14 +28,20 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent 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.SolidColor import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.LocalContentColor import com.konyaco.fluent.LocalTextStyle +import com.konyaco.fluent.background.BackgroundSizing import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.collectVisualState @Composable fun TextField( @@ -43,74 +56,138 @@ fun TextField( keyboardActions: KeyboardActions = KeyboardActions(), maxLines: Int = Int.MAX_VALUE, header: (@Composable () -> Unit)? = null, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } + placeholder: (@Composable () -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + colors: TextFieldColorScheme = TextFieldDefaults.defaultTextFieldColors() ) { - val hovered by interactionSource.collectIsHoveredAsState() - val pressed by interactionSource.collectIsPressedAsState() - val focused by interactionSource.collectIsFocusedAsState() - + val color = colors.schemeFor(interactionSource.collectVisualState(!enabled, focusFirst = true)) Column(modifier) { if (header != null) { header() Spacer(Modifier.height(8.dp)) } BasicTextField( - modifier = modifier.defaultMinSize(160.dp, 32.dp) + modifier = modifier.defaultMinSize(64.dp, 32.dp) .clip(RoundedCornerShape(4.dp)), value = value, onValueChange = onValueChange, - textStyle = LocalTextStyle.current.copy( - color = if (enabled) FluentTheme.colors.text.text.primary - else FluentTheme.colors.text.text.disabled - ), + textStyle = LocalTextStyle.current.copy(color = color.contentColor), enabled = enabled, readOnly = readOnly, singleLine = singleLine, visualTransformation = visualTransformation, maxLines = maxLines, keyboardActions = keyboardActions, - cursorBrush = SolidColor(FluentTheme.colors.text.text.primary), + cursorBrush = color.cursorBrush, keyboardOptions = keyboardOptions, interactionSource = interactionSource, decorationBox = { innerTextField -> - Layer( - modifier = Modifier.hoverable(interactionSource) - .drawBottomLine(enabled) { focused }, - shape = RoundedCornerShape(4.dp), - border = BorderStroke( - 1.dp, - if (focused || pressed) SolidColor(FluentTheme.colors.stroke.control.default) - else FluentTheme.colors.borders.textControl - ), - color = when { - !enabled -> FluentTheme.colors.control.disabled - pressed || focused -> FluentTheme.colors.control.inputActive - hovered -> FluentTheme.colors.control.secondary - else -> FluentTheme.colors.control.default - }, - ) { - Box( - Modifier.offset(y = (-1).dp).padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 3.dp), - Alignment.CenterStart + TextFieldDefaults.DecorationBox( + color = color, + interactionSource = interactionSource, + innerTextField = innerTextField, + value = value.text, + enabled = enabled, + placeholder = placeholder + ) + } + ) + } +} + +object TextFieldDefaults { + + @Stable + @Composable + fun defaultTextFieldColors( + default: TextFieldColor = TextFieldColor( + fillColor = FluentTheme.colors.control.default, + contentColor = FluentTheme.colors.text.text.primary, + placeholderColor = FluentTheme.colors.text.text.secondary, + bottomLineFillColor = FluentTheme.colors.stroke.controlStrong.default, + borderBrush = FluentTheme.colors.borders.textControl, + cursorBrush = SolidColor(FluentTheme.colors.text.text.primary) + ), + focused: TextFieldColor = default.copy( + fillColor = FluentTheme.colors.control.inputActive, + bottomLineFillColor = FluentTheme.colors.fillAccent.default, + borderBrush = SolidColor(FluentTheme.colors.stroke.control.default) + ), + hovered: TextFieldColor = default.copy( + fillColor = FluentTheme.colors.control.secondary + ), + pressed: TextFieldColor = default.copy( + fillColor = FluentTheme.colors.control.inputActive, + borderBrush = SolidColor(FluentTheme.colors.stroke.control.default) + ), + disabled: TextFieldColor = default.copy( + contentColor = FluentTheme.colors.text.text.disabled, + placeholderColor = FluentTheme.colors.text.text.disabled, + bottomLineFillColor = Color.Transparent, + ) + ) = TextFieldColorScheme( + default = default, + focused = focused, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) + + @Composable + fun DecorationBox( + value: String, + interactionSource: MutableInteractionSource, + enabled: Boolean, + color: TextFieldColor, + modifier: Modifier = Modifier.drawBottomLine(enabled, color, interactionSource), + placeholder: (@Composable () -> Unit)?, + innerTextField: @Composable () -> Unit, + ) { + Layer( + modifier = modifier.hoverable(interactionSource), + shape = RoundedCornerShape(4.dp), + color = color.fillColor, + border = BorderStroke(1.dp, color.borderBrush), + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) { + Box( + Modifier.offset(y = (-1).dp).padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 3.dp), + Alignment.CenterStart + ) { + innerTextField() + if (value.isEmpty() && placeholder != null) { + CompositionLocalProvider( + LocalContentColor provides color.placeholderColor, + LocalTextStyle provides LocalTextStyle.current.copy(color = color.placeholderColor) ) { - innerTextField() + placeholder() } } } - ) + } } } +typealias TextFieldColorScheme = PentaVisualScheme + +@Immutable +data class TextFieldColor( + val fillColor: Color, + val contentColor: Color, + val placeholderColor: Color, + val bottomLineFillColor: Color, + val borderBrush: Brush, + val cursorBrush: Brush +) + @Composable -private fun Modifier.drawBottomLine(enabled: Boolean, focused: () -> Boolean): Modifier { +private fun Modifier.drawBottomLine(enabled: Boolean, color: TextFieldColor, interactionSource: MutableInteractionSource): Modifier { + val isFocused by interactionSource.collectIsFocusedAsState() return if (enabled) { val height by rememberUpdatedState(with(LocalDensity.current) { - (if (focused()) 2.dp else 1.dp).toPx() + (if (isFocused) 2.dp else 1.dp).toPx() }) - val fillColor by rememberUpdatedState( - if (focused()) FluentTheme.colors.fillAccent.default - else FluentTheme.colors.stroke.controlStrong.default - ) + val fillColor by rememberUpdatedState(color.bottomLineFillColor) drawWithContent { drawContent() drawRect( diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/scheme/VisualStateScheme.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/scheme/VisualStateScheme.kt new file mode 100644 index 00000000..98b759be --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/scheme/VisualStateScheme.kt @@ -0,0 +1,75 @@ +package com.konyaco.fluent.scheme + +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue + +@Composable +fun InteractionSource.collectVisualState(disabled: Boolean, focusFirst: Boolean = false): VisualState { + val pressed by collectIsPressedAsState() + val hovered by collectIsHoveredAsState() + val focused by collectIsFocusedAsState() + return if (focusFirst) VisualState.fromInteractionFocusFirst(pressed, hovered, disabled, focused) + else VisualState.fromInteraction(pressed, hovered, disabled, focused) +} + +enum class VisualState { + Default, Hovered, Pressed, Disabled, Focused; + + companion object { + fun fromInteraction( + pressed: Boolean, + hovered: Boolean, + disabled: Boolean, + focused: Boolean + ): VisualState { + return when { + disabled -> Disabled + pressed -> Pressed + hovered -> Hovered + focused -> Focused + else -> Default + } + } + + fun fromInteractionFocusFirst( + pressed: Boolean, + hovered: Boolean, + disabled: Boolean, + focused: Boolean + ): VisualState { + return when { + disabled -> Disabled + focused -> Focused + pressed -> Pressed + hovered -> Hovered + else -> Default + } + } + } +} + +fun interface VisualStateScheme { + fun schemeFor(state: VisualState): T +} + +@Immutable +data class PentaVisualScheme( + val default: T, + val hovered: T, + val pressed: T, + val disabled: T, + val focused: T = default +) : VisualStateScheme { + override fun schemeFor(state: VisualState): T = when (state) { + VisualState.Default -> default + VisualState.Hovered -> hovered + VisualState.Pressed -> pressed + VisualState.Disabled -> disabled + VisualState.Focused -> focused + } +} \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/shape/FluentCornerSize.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/shape/FluentCornerSize.kt new file mode 100644 index 00000000..776d376a --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/shape/FluentCornerSize.kt @@ -0,0 +1,161 @@ +/* + * Note: This is an modified version of the original source code from the Android Open Source Project, in order to solve several bugs. + * These code should be synchronized with the original source code. + * + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.konyaco.fluent.shape + +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.ZeroCornerSize + +import androidx.compose.runtime.Stable +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.InspectableValue +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp + +/** + * Defines size of a corner in pixels. For example for rounded shape it can be a corner radius. + */ +@Deprecated( + message = "Use CornerSize instead", + replaceWith = ReplaceWith( + expression = "CornerSize", + imports = arrayOf("androidx.compose.foundation.shape.CornerSize") + ) +) +typealias FluentCornerSize = CornerSize + +/** + * Creates [CornerSize] with provided size. + * @param size the corner size defined in [Dp]. + */ +@Deprecated( + message = "Use CornerSize instead", + replaceWith = ReplaceWith( + expression = "CornerSize(size = size)", + imports = arrayOf("androidx.compose.foundation.shape.CornerSize") + ) +) +@Stable +fun FluentCornerSize(size: Dp): FluentCornerSize = CornerSize(size) + +@Deprecated( + message = "Use CornerSize instead", + replaceWith = ReplaceWith( + expression = "CornerSize(size = size)", + imports = arrayOf("androidx.compose.foundation.shape.CornerSize") + ) +) +data class FluentDpCornerSize(val size: Dp) : FluentCornerSize, InspectableValue { + override fun toPx(shapeSize: Size, density: Density) = + with(density) { size.toPx() } + + override fun toString(): String = "CornerSize(size = ${size.value}.dp)" + + override val valueOverride: Dp + get() = size +} + +/** + * Creates [CornerSize] with provided size. + * @param size the corner size defined in pixels. + */ +@Deprecated( + message = "Use CornerSize instead", + replaceWith = ReplaceWith( + expression = "CornerSize(size = size)", + imports = arrayOf("androidx.compose.foundation.shape.CornerSize") + ) +) +@Stable +fun FluentCornerSize(size: Float): FluentCornerSize = CornerSize(size) + +@Deprecated( + message = "Use CornerSize instead", + replaceWith = ReplaceWith( + expression = "CornerSize(size = size)", + imports = arrayOf("androidx.compose.foundation.shape.CornerSize") + ) +) +data class FluentPxCornerSize(val size: Float) : FluentCornerSize, InspectableValue { + override fun toPx(shapeSize: Size, density: Density) = size + + override fun toString(): String = "CornerSize(size = $size.px)" + + override val valueOverride: String + get() = "${size}px" +} + +/** + * Creates [CornerSize] with provided size. + * @param percent the corner size defined in percents of the shape's smaller side. + * Can't be negative or larger then 100 percents. + */ +@Deprecated( + message = "Use CornerSize instead", + replaceWith = ReplaceWith( + expression = "CornerSize(percent = percent)", + imports = arrayOf("androidx.compose.foundation.shape.CornerSize") + ) +) +@Stable +fun FluentCornerSize(/*@IntRange(from = 0, to = 100)*/ percent: Int): FluentCornerSize = CornerSize(percent) + +/** + * Creates [CornerSize] with provided size. + * @param percent the corner size defined in float percents of the shape's smaller side. + * Can't be negative or larger then 100 percents. + */ +@Deprecated( + message = "Use CornerSize instead", + replaceWith = ReplaceWith( + expression = "CornerSize(percent = percent)", + imports = arrayOf("androidx.compose.foundation.shape.CornerSize") + ) +) +data class FluentPercentCornerSize( + /*@FloatRange(from = 0.0, to = 100.0)*/ + val percent: Float +) : FluentCornerSize, InspectableValue { + init { + if (percent < 0 || percent > 100) { + throw IllegalArgumentException("The percent should be in the range of [0, 100]") + } + } + + override fun toPx(shapeSize: Size, density: Density) = + shapeSize.minDimension * (percent / 100f) + + override fun toString(): String = "CornerSize(size = $percent%)" + + override val valueOverride: String + get() = "$percent%" +} + +/** + * [CornerSize] always equals to zero. + */ +@Deprecated( + message = "Use ZeroCornerSize instead", + replaceWith = ReplaceWith( + expression = "ZeroCornerSize", + imports = arrayOf("androidx.compose.foundation.shape.ZeroCornerSize") + ) +) +@Stable +val FluentZeroCornerSize: FluentCornerSize + get() = ZeroCornerSize diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/shape/FluentShape.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/shape/FluentShape.kt new file mode 100644 index 00000000..005b8c95 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/shape/FluentShape.kt @@ -0,0 +1,189 @@ +/* + * Note: This is an modified version of the original source code from the Android Open Source Project, in order to solve several bugs. + * These code should be synchronized with the original source code. + * + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.konyaco.fluent.shape + +import androidx.compose.foundation.shape.* +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +/** + * A shape describing the rectangle with rounded corners. + * + * This shape will automatically mirror the corner sizes in [LayoutDirection.Rtl], use + * [AbsoluteRoundedCornerShape] for the layout direction unaware version of this shape. + * + * @param topStart a size of the top start corner + * @param topEnd a size of the top end corner + * @param bottomEnd a size of the bottom end corner + * @param bottomStart a size of the bottom start corner + */ +@Deprecated( + message = "Use RoundedCornerShape instead", + replaceWith = ReplaceWith( + expression = "RoundedCornerShape(topStart = topStart, topEnd = topEnd, bottomEnd = bottomEnd, bottomStart = bottomStart)", + imports = arrayOf("androidx.compose.foundation.shape.RoundedCornerShape") + ) +) +typealias FluentRoundedCornerShape = RoundedCornerShape + +/** + * Circular [Shape] with all the corners sized as the 50 percent of the shape size. + */ +@Deprecated( + message = "Use CircleShape instead", + replaceWith = ReplaceWith( + expression = "CircleShape", + imports = arrayOf("androidx.compose.foundation.shape.CircleShape") + ) +) +val FluentCircleShape: FluentRoundedCornerShape + get() = CircleShape + +/** + * Creates [RoundedCornerShape] with the same size applied for all four corners. + * @param corner [FluentCornerSize] to apply. + */ +@Deprecated( + message = "Use RoundedCornerShape instead", + replaceWith = ReplaceWith( + expression = "RoundedCornerShape(corner = corner)", + imports = arrayOf("androidx.compose.foundation.shape.RoundedCornerShape") + ) +) +fun FluentRoundedCornerShape(corner: FluentCornerSize) = RoundedCornerShape(corner, corner, corner, corner) + +/** + * Creates [RoundedCornerShape] with the same size applied for all four corners. + * @param size Size in [Dp] to apply. + */ +@Deprecated( + message = "Use RoundedCornerShape instead", + replaceWith = ReplaceWith( + expression = "RoundedCornerShape(size = size)", + imports = arrayOf("androidx.compose.foundation.shape.RoundedCornerShape") + ) +) +fun FluentRoundedCornerShape(size: Dp) = RoundedCornerShape(CornerSize(size)) + +/** + * Creates [RoundedCornerShape] with the same size applied for all four corners. + * @param size Size in pixels to apply. + */ +@Deprecated( + message = "Use RoundedCornerShape instead", + replaceWith = ReplaceWith( + expression = "RoundedCornerShape(size = size)", + imports = arrayOf("androidx.compose.foundation.shape.RoundedCornerShape") + ) +) +fun FluentRoundedCornerShape(size: Float) = RoundedCornerShape(CornerSize(size)) + +/** + * Creates [RoundedCornerShape] with the same size applied for all four corners. + * @param percent Size in percents to apply. + */ +@Deprecated( + message = "Use RoundedCornerShape instead", + replaceWith = ReplaceWith( + expression = "RoundedCornerShape(percent = percent)", + imports = arrayOf("androidx.compose.foundation.shape.RoundedCornerShape") + ) +) +fun FluentRoundedCornerShape(percent: Int) = RoundedCornerShape(CornerSize(percent)) + +/** + * Creates [RoundedCornerShape] with sizes defined in [Dp]. + */ +@Deprecated( + message = "Use RoundedCornerShape instead", + replaceWith = ReplaceWith( + expression = "RoundedCornerShape(topStart = topStart, topEnd = topEnd, bottomEnd = bottomEnd, bottomStart = bottomStart)", + imports = arrayOf("androidx.compose.foundation.shape.RoundedCornerShape") + ) +) +fun FluentRoundedCornerShape( + topStart: Dp = 0.dp, + topEnd: Dp = 0.dp, + bottomEnd: Dp = 0.dp, + bottomStart: Dp = 0.dp +) = FluentRoundedCornerShape( + topStart = FluentCornerSize(topStart), + topEnd = FluentCornerSize(topEnd), + bottomEnd = FluentCornerSize(bottomEnd), + bottomStart = FluentCornerSize(bottomStart) +) + +/** + * Creates [RoundedCornerShape] with sizes defined in pixels. + */ +@Deprecated( + message = "Use RoundedCornerShape instead", + replaceWith = ReplaceWith( + expression = "RoundedCornerShape(topStart = topStart, topEnd = topEnd, bottomEnd = bottomEnd, bottomStart = bottomStart)", + imports = arrayOf("androidx.compose.foundation.shape.RoundedCornerShape") + ) +) +fun FluentRoundedCornerShape( + topStart: Float = 0.0f, + topEnd: Float = 0.0f, + bottomEnd: Float = 0.0f, + bottomStart: Float = 0.0f +) = FluentRoundedCornerShape( + topStart = FluentCornerSize(topStart), + topEnd = FluentCornerSize(topEnd), + bottomEnd = FluentCornerSize(bottomEnd), + bottomStart = FluentCornerSize(bottomStart) +) + +/** + * Creates [RoundedCornerShape] with sizes defined in percents of the shape's smaller side. + * + * @param topStartPercent The top start corner radius as a percentage of the smaller side, with a + * range of 0 - 100. + * @param topEndPercent The top end corner radius as a percentage of the smaller side, with a + * range of 0 - 100. + * @param bottomEndPercent The bottom end corner radius as a percentage of the smaller side, + * with a range of 0 - 100. + * @param bottomStartPercent The bottom start corner radius as a percentage of the smaller side, + * with a range of 0 - 100. + */ +@Deprecated( + message = "Use RoundedCornerShape instead", + replaceWith = ReplaceWith( + expression = "RoundedCornerShape(topStartPercent = topStartPercent, topEndPercent = topEndPercent, bottomEndPercent = bottomEndPercent, bottomStartPercent = bottomStartPercent)", + imports = arrayOf("androidx.compose.foundation.shape.RoundedCornerShape") + ) +) +fun FluentRoundedCornerShape( + /*@IntRange(from = 0, to = 100)*/ + topStartPercent: Int = 0, + /*@IntRange(from = 0, to = 100)*/ + topEndPercent: Int = 0, + /*@IntRange(from = 0, to = 100)*/ + bottomEndPercent: Int = 0, + /*@IntRange(from = 0, to = 100)*/ + bottomStartPercent: Int = 0 +) = FluentRoundedCornerShape( + topStart = FluentCornerSize(topStartPercent), + topEnd = FluentCornerSize(topEndPercent), + bottomEnd = FluentCornerSize(bottomEndPercent), + bottomStart = FluentCornerSize(bottomStartPercent) +) \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/surface/Card.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/surface/Card.kt index f3836df0..91810c2d 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/surface/Card.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/surface/Card.kt @@ -1,10 +1,111 @@ package com.konyaco.fluent.surface +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.animation.FluentDuration +import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.BackgroundSizing import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.VisualStateScheme +import com.konyaco.fluent.scheme.collectVisualState @Composable -fun Card(modifier: Modifier, content: @Composable () -> Unit) { - Layer(modifier, outsideBorder = true, content = content) +fun Card( + modifier: Modifier, + shape: Shape = RoundedCornerShape(size = 8.dp), + content: @Composable () -> Unit +) { + Layer( + modifier = modifier, + shape = shape, + backgroundSizing = BackgroundSizing.InnerBorderEdge, + content = content + ) +} + +@Composable +fun Card( + onClick: () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(size = 4.dp), + disabled: Boolean = false, + cardColors: VisualStateScheme = CardDefaults.cardColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit +) { + val visualState = interactionSource.collectVisualState(disabled) + val colors = cardColors.schemeFor(visualState) + + val fillColor by animateColorAsState( + colors.fillColor, + animationSpec = tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + ) + + val contentColor by animateColorAsState( + colors.contentColor, + animationSpec = tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + ) + + Layer( + modifier = modifier.clickable( + enabled = !disabled, + onClick = onClick, + indication = null, + interactionSource = interactionSource + ), + shape = shape, + backgroundSizing = BackgroundSizing.InnerBorderEdge, + color = fillColor, + border = BorderStroke(1.dp, colors.borderBrush), + contentColor = contentColor, + content = content + ) +} + +data class CardColor( + val fillColor: Color, + val contentColor: Color, + val borderBrush: Brush +) + +object CardDefaults { + @Stable + @Composable + fun cardColors( + default: CardColor = CardColor( + fillColor = FluentTheme.colors.background.layer.default, + contentColor = FluentTheme.colors.text.text.primary, + borderBrush = SolidColor(FluentTheme.colors.stroke.card.default) + ), + hovered: CardColor = default.copy( + fillColor = FluentTheme.colors.control.secondary, + borderBrush = FluentTheme.colors.borders.control + ), + pressed: CardColor = CardColor( + fillColor = FluentTheme.colors.control.tertiary, + contentColor = FluentTheme.colors.text.text.secondary, + borderBrush = SolidColor(FluentTheme.colors.stroke.control.default) + ), + disabled: CardColor = pressed.copy( + fillColor = FluentTheme.colors.background.layer.default, + contentColor = FluentTheme.colors.text.text.primary, + borderBrush = SolidColor(FluentTheme.colors.stroke.card.default) + ) + ) = PentaVisualScheme(default, hovered, pressed, disabled) } \ No newline at end of file diff --git a/fluent/src/desktopMain/kotlin/com/konyaco/fluent/PlatformCompositionLocalProvider.desktop.kt b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/PlatformCompositionLocalProvider.desktop.kt new file mode 100644 index 00000000..5a7bff46 --- /dev/null +++ b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/PlatformCompositionLocalProvider.desktop.kt @@ -0,0 +1,21 @@ +package com.konyaco.fluent + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.LocalContextMenuRepresentation +import androidx.compose.foundation.text.LocalTextContextMenu +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import com.konyaco.fluent.component.FluentContextMenuRepresentation +import com.konyaco.fluent.component.FluentTextContextMenu + +@OptIn(ExperimentalFoundationApi::class) +@Composable +actual fun PlatformCompositionLocalProvider(content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalTextContextMenu provides FluentTextContextMenu, + LocalContextMenuRepresentation provides FluentContextMenuRepresentation + ) { + content() + } +} + diff --git a/fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/ContextMenu.desktop.kt b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/ContextMenu.desktop.kt new file mode 100644 index 00000000..92ccd8dd --- /dev/null +++ b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/ContextMenu.desktop.kt @@ -0,0 +1,228 @@ +package com.konyaco.fluent.component + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.TextContextMenu +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.* +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLocalization +import androidx.compose.ui.unit.* +import com.konyaco.fluent.animation.FluentDuration +import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.regular.Copy +import com.konyaco.fluent.icons.regular.Cut +import com.konyaco.fluent.icons.regular.ClipboardPaste + +internal object FluentContextMenuRepresentation : ContextMenuRepresentation { + @Composable + override fun Representation(state: ContextMenuState, items: () -> List) { + var rect by remember { + mutableStateOf(Rect.Zero) + } + var visible by remember { + mutableStateOf(false) + } + val status = state.status + LaunchedEffect(status) { + if (status is ContextMenuState.Status.Open) { + rect = status.rect + visible = true + } else { + visible = false + } + } + val density = LocalDensity.current + MenuFlyout( + visible = visible, + onDismissRequest = { state.status = ContextMenuState.Status.Closed }, + onKeyEvent = { keyEvent -> + items().firstOrNull { + val result = it is FluentContextMenuItem && + keyEvent.type == KeyEventType.KeyDown && + it.keyData != null && + it.keyData.isAltPressed == keyEvent.isAltPressed && + it.keyData.isCtrlPressed == keyEvent.isCtrlPressed && + it.keyData.isShiftPressed == keyEvent.isShiftPressed && + it.keyData.key == keyEvent.key + if (result) { + it.onClick() + state.status = ContextMenuState.Status.Closed + } + result + } != null + }, + positionProvider = remember(rect, density) { + ContextMenuFlyoutPositionProvider(rect, density) + }, + enterPlacementAnimation = { enterAnimation() } + ) { + val menuItems = items() + val shouldPaddingIcon = + menuItems.any { it is FluentContextMenuItem && (it.glyph != null || it.vector != null) } + menuItems.forEach { + if (it is FluentContextMenuItem) { + MenuFlyoutItem( + text = { + Text(it.label, modifier = Modifier) + }, + icon = if (shouldPaddingIcon) { + { + if (it.glyph != null && LocalFontIconFontFamily.current != null) { + FontIcon(it.glyph, modifier = Modifier) + } else if (it.vector != null) { + Icon( + it.vector, it.label, + modifier = Modifier.size(with(LocalDensity.current) { ((FontIconDefaults.fontSizeStandard.value + 2).sp).toDp() }) + ) + } + } + } else { + null + }, + training = { + it.keyData?.let { keyData -> + val keyString = remember(keyData) { + buildString { + if (keyData.isAltPressed) { + append("Alt+") + } + if (keyData.isCtrlPressed) { + append("Ctrl+") + } + if (keyData.isShiftPressed) { + append("Shift+") + } + append(keyData.key.toString().removePrefix("Key: ")) + } + } + Text( + text = keyString, + modifier = Modifier.padding(start = 16.dp, end = 8.dp) + ) + } + }, + onClick = { + it.onClick() + state.status = ContextMenuState.Status.Closed + } + ) + } else { + MenuFlyoutItem( + onClick = { + it.onClick() + state.status = ContextMenuState.Status.Closed + }, + text = { Text(it.label) }, + icon = if (shouldPaddingIcon) { {} } else { null }, + ) + } + } + } + } + + + private fun enterAnimation(): EnterTransition { + return fadeIn(defaultAnimationSpec()) + } + + private fun defaultAnimationSpec() = + tween(FluentDuration.ShortDuration, easing = FluentEasing.FastInvokeEasing) +} + +@OptIn(ExperimentalFoundationApi::class) +internal object FluentTextContextMenu : TextContextMenu { + + @Composable + override fun Area( + textManager: TextContextMenu.TextManager, + state: ContextMenuState, + content: @Composable () -> Unit + ) { + val localization = LocalLocalization.current + val items = { + listOfNotNull( + textManager.cut?.let { + FluentContextMenuItem( + label = localization.cut, + onClick = it, + glyph = '\uE8C6', + vector = Icons.Default.Cut, + keyData = FluentContextMenuItem.KeyData(Key.X, isCtrlPressed = true) + ) + }, + textManager.copy?.let { + FluentContextMenuItem( + label = localization.copy, + onClick = it, + glyph = '\uE8C8', + vector = Icons.Default.Copy, + keyData = FluentContextMenuItem.KeyData(Key.C, isCtrlPressed = true) + ) + }, + textManager.paste?.let { + FluentContextMenuItem( + label = localization.paste, + onClick = it, + glyph = '\uE77F', + vector = Icons.Default.ClipboardPaste, + keyData = FluentContextMenuItem.KeyData(Key.V, isCtrlPressed = true) + ) + }, + textManager.selectAll?.let { + FluentContextMenuItem( + label = localization.selectAll, + onClick = it, + keyData = FluentContextMenuItem.KeyData(Key.A, isCtrlPressed = true), + ) + }, + ) + } + ContextMenuArea(items, state, content = content) + } +} + +class FluentContextMenuItem( + label: String, + onClick: () -> Unit, + val vector: ImageVector? = null, + val keyData: KeyData? = null, + val glyph: Char? = null +) : ContextMenuItem(label, onClick) { + data class KeyData( + val key: Key, + val isAltPressed: Boolean = false, + val isCtrlPressed: Boolean = false, + val isShiftPressed: Boolean = false + ) +} + +private class ContextMenuFlyoutPositionProvider( + val rect: Rect, + density: Density, +) : FlyoutPositionProvider( + density = density, + adaptivePlacement = true, + initialPlacement = FlyoutPlacement.BottomAlignedStart +) { + + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + val targetAnchor = IntRect( + offset = rect.center.round() + anchorBounds.topLeft, + size = IntSize.Zero + ) + return super.calculatePosition(targetAnchor, windowSize, layoutDirection, popupContentSize) + } +} \ No newline at end of file diff --git a/fluent/src/jvmMain/kotlin/com/konyaco/fluent/component/Dialog.desktop.kt b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/Dialog.desktop.kt similarity index 100% rename from fluent/src/jvmMain/kotlin/com/konyaco/fluent/component/Dialog.desktop.kt rename to fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/Dialog.desktop.kt diff --git a/fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/FontIcon.desktop.kt b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/FontIcon.desktop.kt new file mode 100644 index 00000000..7854d0e6 --- /dev/null +++ b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/FontIcon.desktop.kt @@ -0,0 +1,32 @@ +package com.konyaco.fluent.component + +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.FontLoadResult + +@OptIn(ExperimentalTextApi::class) +@Composable +internal actual fun ProvideFontIcon(content: @Composable () -> Unit) { + val fontFamilyResolver = LocalFontFamilyResolver.current + var fontIconFamily by remember { + mutableStateOf(null) + } + LaunchedEffect(fontFamilyResolver) { + val fontName = "Segoe Fluent Icons" + val fontFamily = FontFamily(fontName) + fontIconFamily = kotlin.runCatching { + val result = fontFamilyResolver.resolve(fontFamily).value as FontLoadResult + if (result.typeface == null || result.typeface?.familyName != fontName) { + null + } else { + fontFamily + } + }.getOrNull() + } + CompositionLocalProvider( + LocalFontIconFontFamily provides fontIconFamily, + content = content + ) +} \ No newline at end of file diff --git a/fluent/src/jvmMain/kotlin/com/konyaco/fluent/component/PlatformScrollBar.desktop.kt b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/PlatformScrollBar.desktop.kt similarity index 100% rename from fluent/src/jvmMain/kotlin/com/konyaco/fluent/component/PlatformScrollBar.desktop.kt rename to fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/PlatformScrollBar.desktop.kt diff --git a/fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/Popup.desktop.kt b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/Popup.desktop.kt new file mode 100644 index 00000000..01e4ca1f --- /dev/null +++ b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/Popup.desktop.kt @@ -0,0 +1,16 @@ +package com.konyaco.fluent.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties + +@Composable +internal actual fun Popup( + popupPositionProvider: PopupPositionProvider, + onDismissRequest: (() -> Unit)?, + properties: PopupProperties, + onPreviewKeyEvent: ((KeyEvent) -> Boolean)?, + onKeyEvent: ((KeyEvent) -> Boolean)?, + content: @Composable () -> Unit +) = androidx.compose.ui.window.Popup(popupPositionProvider, onDismissRequest, properties, onPreviewKeyEvent, onKeyEvent, content) \ No newline at end of file diff --git a/fluent/src/jvmMain/kotlin/com/konyaco/fluent/component/rememberResourcePainter.kt b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/rememberResourcePainter.kt similarity index 100% rename from fluent/src/jvmMain/kotlin/com/konyaco/fluent/component/rememberResourcePainter.kt rename to fluent/src/desktopMain/kotlin/com/konyaco/fluent/component/rememberResourcePainter.kt diff --git a/fluent/src/desktopMain/kotlin/com/konyaco/fluent/defaultFontFamily.desktop.kt b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/defaultFontFamily.desktop.kt new file mode 100644 index 00000000..d8df06b4 --- /dev/null +++ b/fluent/src/desktopMain/kotlin/com/konyaco/fluent/defaultFontFamily.desktop.kt @@ -0,0 +1,9 @@ +package com.konyaco.fluent + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontFamily + +@Composable +actual fun defaultFontFamily(): FontFamily? { + return null +} \ No newline at end of file diff --git a/fluent/src/jvmMain/resources/NoiseAsset_256.png b/fluent/src/desktopMain/resources/NoiseAsset_256.png similarity index 100% rename from fluent/src/jvmMain/resources/NoiseAsset_256.png rename to fluent/src/desktopMain/resources/NoiseAsset_256.png diff --git a/fluent/src/jvmMain/kotlin/com/konyaco/fluent/defaultFontFamily.desktop.kt b/fluent/src/jvmMain/kotlin/com/konyaco/fluent/defaultFontFamily.desktop.kt deleted file mode 100644 index 0183a180..00000000 --- a/fluent/src/jvmMain/kotlin/com/konyaco/fluent/defaultFontFamily.desktop.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.konyaco.fluent - -import androidx.compose.runtime.Composable -import androidx.compose.ui.text.font.FontFamily - -@Composable -actual fun defaultFontFamily(): FontFamily? { - return null -} - -/* -@Composable -actual fun defaultFontFamily(): FontFamily? { - val state = remember { - mutableStateOf(null) - } - LaunchedEffect(state) { - state.value = AwtFontManager.DEFAULT.findFontFamilyFile("Segoe UI Variable")?.let { - Font(it).toFontFamily() - } - } - return state.value -}*/ diff --git a/gallery-processor/.gitignore b/gallery-processor/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/gallery-processor/build.gradle.kts b/gallery-processor/build.gradle.kts new file mode 100644 index 00000000..8212d3de --- /dev/null +++ b/gallery-processor/build.gradle.kts @@ -0,0 +1,22 @@ +import com.konyaco.fluent.plugin.build.BuildConfig + +plugins { + alias(libs.plugins.kotlin.multiplatform) +} + +group = BuildConfig.group +version = BuildConfig.libraryVersion + +kotlin { + jvm() + sourceSets { + val jvmMain by getting { + dependencies { + implementation(libs.squareup.kotlinpoet) + implementation("com.google.devtools.ksp:symbol-processing-api:${libs.versions.ksp.get()}") + implementation("com.google.devtools.ksp:symbol-processing:${libs.versions.ksp.get()}") + implementation(kotlin("compiler")) + } + } + } +} \ No newline at end of file diff --git a/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/CommonProcessor.kt b/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/CommonProcessor.kt new file mode 100644 index 00000000..abfa294b --- /dev/null +++ b/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/CommonProcessor.kt @@ -0,0 +1,46 @@ +package com.konyaco.fluent.gallery.processor + +import com.google.devtools.ksp.processing.* +import com.google.devtools.ksp.symbol.KSAnnotated + +class CommonProcessor(logger: KSPLogger, codeGenerator: CodeGenerator): SymbolProcessor { + + private val visitor = Visitor( + onPropertyNode = { property -> + processors.forEach { it.onPropertyVisit(property) } + }, + onFunNode = { function -> + processors.forEach { it.onFunctionVisit(function) } + } + ) + + private val processors = listOf( + SampleCodeProcessor(logger, codeGenerator), + ComponentProcessor(logger, codeGenerator) + ) + + override fun process(resolver: Resolver): List { + resolver.getAllFiles().forEach { it.accept(visitor, Unit) } + return processors.flatMap { it.process(resolver) } + } + + override fun finish() { + super.finish() + processors.forEach { it.finish() } + } + + override fun onError() { + super.onError() + processors.forEach { it.onError() } + } + + companion object { + const val annotationPackage = "com.konyaco.fluent.gallery.annotation" + } +} + +class CommonProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return CommonProcessor(environment.logger, environment.codeGenerator) + } +} \ No newline at end of file diff --git a/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/ComponentProcessor.kt b/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/ComponentProcessor.kt new file mode 100644 index 00000000..f716b2b7 --- /dev/null +++ b/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/ComponentProcessor.kt @@ -0,0 +1,400 @@ +package com.konyaco.fluent.gallery.processor + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.symbol.* +import com.google.devtools.ksp.symbol.impl.kotlin.KSPropertyDeclarationImpl +import com.squareup.kotlinpoet.* +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import org.jetbrains.kotlin.util.prefixIfNot +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets + +class ComponentProcessor(private val logger: KSPLogger, private val codeGenerator: CodeGenerator) : IProcessor { + + private val componentAnnotation = "Component" + private val componentGroupAnnotation = "ComponentGroup" + + private val iconImportPrefix = "com.konyaco.fluent.icons.regular" + private val iconPrefix = "com.konyaco.fluent.icons.Icons.Regular" + + private val componentFunctions = mutableMapOf>>() + private val componentGroups = mutableMapOf>() + private val componentPackageMap = mutableMapOf() + + private val componentPackage = "com.konyaco.fluent.gallery.component" + private val componentItemClass = ClassName(componentPackage, "ComponentItem") + + private val componentNameList = mutableListOf() + + private val propertyNameRegex = Regex("^[a-zA-Z_]*\\w") + + private val componentPagePathType = TypeSpec.objectBuilder("ComponentPagePath") + + override fun finish() { + super.finish() + arrangeComponentGroup() + generateComponents() + } + + private fun arrangeComponentGroup() { + val mapPackage = componentFunctions.remove("/_Auto") ?: emptyList() + mapPackage.forEach { pair -> + val group = componentPackageMap[pair.second.packageName.asString()] + if (!group.isNullOrEmpty()) { + val list = + componentFunctions[group] ?: mutableListOf>().apply { + componentFunctions[group] = this + } + list.add(pair) + } else { + val list = componentFunctions["/"] ?: mutableListOf>().apply { + componentFunctions["/"] = this + } + list.add(pair) + } + } + } + + override fun onFunctionVisit(function: KSFunctionDeclaration) { + super.onFunctionVisit(function) + function.annotations.forEach { annotation -> + if (annotation.isTargetAnnotation(componentAnnotation)) { + var groupArg: KSValueArgument? = null + annotation.arguments.forEach { arg -> + when (arg.name?.asString()) { + "group" -> groupArg = arg + } + } + val group = (groupArg?.value as? String)?.prefixIfNot("/") ?: return@forEach + val list = + componentFunctions[group] ?: mutableListOf>().apply { + componentFunctions[group] = this + } + list.add(annotation to function) + return + } + } + } + + override fun onPropertyVisit(property: KSPropertyDeclaration) { + super.onPropertyVisit(property) + val annotation = property.annotations.firstOrNull { + it.isTargetAnnotation(componentGroupAnnotation) + } ?: return + if (property is KSPropertyDeclarationImpl) { + val groupName = + property.ktProperty.initializer?.text?.removePrefix("\"")?.removeSuffix("\"")?.prefixIfNot("/") + ?: return + componentGroups[groupName] = annotation to property + val packageNameValue = + annotation.arguments.firstOrNull { it.name?.asString() == "packageMap" }?.value as? String + val packageName = packageNameValue?.ifBlank { null } ?: return + componentPackageMap[packageName] = groupName + } + } + + private fun generateComponents() { + + val fileSpecBuilder = FileSpec.builder(componentPackage, "Components") + val listComponentsType = List::class.asTypeName().parameterizedBy(componentItemClass) + val rootComponent = + PropertySpec.builder("components", listComponentsType) + .addModifiers(KModifier.INTERNAL) + val keySets = mutableSetOf() + componentFunctions.keys.forEach { + var route = "" + it.split('/').forEach { node -> + if (node.isNotEmpty()) { + route += "/$node" + keySets.add(route) + } + } + } + val levelKey = keySets.groupBy { it.substringBeforeLast('/') } + levelKey.entries.sortedByDescending { (group, _) -> + group.count { it == '/' } + }.forEach { (group, items) -> + val actualItems = items.filter { (it.isNotBlank() && it != "/") } + val key = group.ifEmpty { "/" } + if (group == "") { + + fileSpecBuilder.addProperty( + rootComponent + .lazy { + val itemNames = createItemsString( + group = "", + fileSpec = fileSpecBuilder, + functions = componentFunctions[key], + childNodes = actualItems.map { + componentGroups[it]?.first to generateComponentsFullName(it) + } + ) + if (itemNames == null) { + addStatement("emptyList()") + } else { + createList("", itemNames) { (_, name) -> name } + } + }.build() + ) + + } + actualItems.forEach { item -> + val itemName = if (item != "/") { + item.substringAfterLast('/') + } else { + "" + } + val propertyName = generateComponentsFullName(item) + val componentGroupConfig = generateComponentGroupConfig(item) + val functionItems = componentFunctions[item] + val childNodeItems = levelKey[item] + fileSpecBuilder.addProperty( + PropertySpec.builder(propertyName, componentItemClass) + .addModifiers(KModifier.INTERNAL) + .initializer( + componentItemInitializerString( + name = itemName, + group = item.substringBeforeLast('/'), + description = "", + icon = componentGroupConfig.icon?.run { + fileSpecBuilder.addImport(iconImportPrefix, this) + "$iconPrefix.$this" + }, + content = componentGroupConfig.contentData, + items = createItemsString( + itemName, + fileSpecBuilder, + functionItems, + childNodeItems?.map { + componentGroups[it]?.first to generateComponentsFullName(it) + }), + getItem = { (_, name) -> name } + ) + ) + .build() + ) + } + + } + fileSpecBuilder.addProperty( + PropertySpec.builder( + name = "flatMapComponents", + type = listComponentsType + ).addModifiers(KModifier.INTERNAL) + .lazy { createList("", componentNameList) { it } } + .build() + ) + val file = codeGenerator.createNewFile( + Dependencies(true), + fileSpecBuilder.packageName, + fileSpecBuilder.name + ) + val pathFileSpec = FileSpec.builder( + componentPackage, "ComponentPagePath" + ).addType(componentPagePathType.build()).build() + val pathFile = codeGenerator.createNewFile( + Dependencies(true), + componentPackage, + pathFileSpec.name + ) + OutputStreamWriter(file, StandardCharsets.UTF_8).use(fileSpecBuilder.build()::writeTo) + OutputStreamWriter(pathFile, StandardCharsets.UTF_8).use(pathFileSpec::writeTo) + } + + private fun createItemsString( + group: String, + fileSpec: FileSpec.Builder, + functions: List>?, + childNodes: List>? + ): List>? { + val functionItems = functions ?: emptyList() + val childNodeItems = childNodes ?: emptyList() + return if (functions.isNullOrEmpty() && childNodes.isNullOrEmpty()) { + null + } else { + (functionItems.map { (annotation, function) -> + annotation to generateComponentItemProperty(group, fileSpec, function, annotation) + } + childNodeItems).sortedBy { (annotation, _) -> + (annotation?.arguments?.first { arg -> arg.name?.asString() == "index" }?.value as? Int) + ?: Int.MAX_VALUE + } + } + } + + private fun CodeBlock.Builder.createList( + prefix: String, + items: List, + item: (T) -> String + ): CodeBlock.Builder = addStatement("${prefix}listOf(") + .withTwoIndent { + items.forEachIndexed { index, t -> + val string = item(t) + if (index != items.lastIndex) { + addStatement("$string,") + } else { + addStatement(string) + } + } + } + .addStatement(")") + + private fun generateComponentsFullName(group: String): String { + return (group.replace( + "/", + "_" + ) + "Components").asPropertyName() + } + + private fun generateComponentItemProperty( + group: String, + fileSpec: FileSpec.Builder, + functionDeclaration: KSFunctionDeclaration, + annotation: KSAnnotation + ): String { + val simpleNameString = functionDeclaration.simpleName.asString() + val packageNameString = functionDeclaration.packageName.asString() + val functionName = group + "_" + simpleNameString + "Component" + var nameArg: KSValueArgument? = null + var descriptionArg: KSValueArgument? = null + var icon: String? = null + annotation.arguments.forEach { + when (it.name?.asString()) { + "name" -> nameArg = it + "description" -> descriptionArg = it + "icon" -> icon = (it.value as? String)?.ifBlank { null } + } + } + val description = descriptionArg?.value as? String ?: "" + fileSpec.addImport( + ClassName( + packageNameString.substringBeforeLast("."), + packageNameString.substringAfterLast(".") + ), simpleNameString + ) + val propertyName = functionName.asPropertyName() + val componentName = (nameArg?.value as? String)?.ifBlank { null } + ?: functionDeclaration.simpleName.asString().removeSuffix("Screen") + val sourceFile = functionDeclaration.containingFile + if (sourceFile != null) { + val relativePath = sourceFile.filePath.substringAfterLast("gallery/src/") + componentPagePathType + .addModifiers(KModifier.INTERNAL) + .addProperty( + PropertySpec.builder( + "${componentName}Screen".replace(" ", "_"), + String::class + ) + .addModifiers(KModifier.CONST) + .initializer("\"$relativePath\"") + .build() + ) + } + componentNameList.add(propertyName) + var arg = "" + functionDeclaration.parameters.forEach { + val type = it.type.resolve().declaration + if (type.simpleName.asString() == "ComponentNavigator" && type.packageName.asString() == componentPackage) { + arg = "${it.name?.asString()} = it" + } + } + fileSpec.addProperty( + PropertySpec.builder(propertyName, componentItemClass) + .addModifiers(KModifier.INTERNAL) + .initializer( + componentItemInitializerString( + name = componentName, + group = group, + description = description, + content = "{ ${simpleNameString}($arg) }", + icon = icon?.run { + fileSpec.addImport(iconImportPrefix, this) + "$iconPrefix.$this" + }, + items = null, + getItem = { it } + ) + ) + .build() + ) + return propertyName + } + + private fun componentItemInitializerString( + name: String, + group: String, + description: String, + content: String?, + icon: String?, + items: List?, + getItem: (T) -> String, + ) = CodeBlock.builder() + .addStatement("ComponentItem(") + .withTwoIndent { + addStatement("name = %S,", name) + addStatement("group = %S,", if (group.isNotBlank()) group.prefixIfNot("/") else "") + addStatement("description = %S,", description) + addStatement("content = $content,") + addStatement("icon = $icon,") + if (items != null) { + createList("items = ", items, getItem) + } else { + addStatement("items = null") + } + } + .addStatement(")") + .build() + + private fun generateComponentGroupConfig(group: String): ComponentGroupConfig { + var icon: String? = null + var contentData: String? = null + componentGroups[group]?.let { (annotation) -> + annotation.arguments.forEach { + when (it.name?.asString()) { + "icon" -> icon = (it.value as? String)?.ifBlank { null } + "generateScreen" -> if (it.value as? Boolean == true) { + contentData = """ + { ComponentIndexScreen(it) } + """.trimIndent() + } + } + } + } + return ComponentGroupConfig(icon, contentData) + } + + private fun PropertySpec.Builder.lazy(buildAction: CodeBlock.Builder.() -> Unit) = delegate( + CodeBlock.builder() + .addStatement("lazy {") + .withTwoIndent { buildAction() } + .addStatement("}") + .build() + ) + + private fun CodeBlock.Builder.withTwoIndent(buildAction: CodeBlock.Builder.() -> Unit): CodeBlock.Builder { + return withIndent { + withIndent(buildAction) + } + } + + private fun String.asPropertyName(): String { + return if (propertyNameRegex.matches(this)) { + this + } else { + buildString(length) { + this@asPropertyName.forEach { + append(when(it) { + ' ', '&', '/' -> '_' + else -> it + }) + } + } + } + } + + data class ComponentGroupConfig( + val icon: String?, + val contentData: String? + ) +} \ No newline at end of file diff --git a/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/IProcessor.kt b/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/IProcessor.kt new file mode 100644 index 00000000..0a2d8bd8 --- /dev/null +++ b/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/IProcessor.kt @@ -0,0 +1,22 @@ +package com.konyaco.fluent.gallery.processor + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration + +interface IProcessor: SymbolProcessor { + fun onFunctionVisit(function: KSFunctionDeclaration) {} + + fun onPropertyVisit(property: KSPropertyDeclaration) {} + + override fun process(resolver: Resolver): List { + return emptyList() + } + + fun KSAnnotation.isTargetAnnotation(targetName: String): Boolean { + return shortName.asString() == targetName && annotationType.resolve().declaration.packageName.asString() == CommonProcessor.annotationPackage + } +} \ No newline at end of file diff --git a/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/SampleCodeProcessor.kt b/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/SampleCodeProcessor.kt new file mode 100644 index 00000000..3aba111c --- /dev/null +++ b/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/SampleCodeProcessor.kt @@ -0,0 +1,85 @@ +package com.konyaco.fluent.gallery.processor + +import com.google.devtools.ksp.processing.* +import com.google.devtools.ksp.symbol.* +import com.google.devtools.ksp.symbol.impl.kotlin.KSFunctionDeclarationImpl +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.PropertySpec +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets + +class SampleCodeProcessor(private val logger: KSPLogger, private val codeGenerator: CodeGenerator) : IProcessor { + + private val sampleAnnotation = "Sample" + + private val sampleCodeFunctions = mutableMapOf>() + + override fun onFunctionVisit(function: KSFunctionDeclaration) { + super.onFunctionVisit(function) + function.annotations.forEach { + if (it.isTargetAnnotation(sampleAnnotation)) { + val list = sampleCodeFunctions[function.packageName.asString()] + ?: mutableListOf().apply { + sampleCodeFunctions[function.packageName.asString()] = this + } + list.add(function) + } + return + } + } + + override fun finish() { + super.finish() + generateSampleCode() + } + + private fun generateSampleCode() { + val fileName = "_SampleCodeString" + sampleCodeFunctions.forEach { (packageName, functions) -> + if (functions.isNotEmpty()) { + val sourceFile = FileSpec.builder(packageName, fileName) + val sourceFileList = mutableListOf() + functions.forEach { func -> + func.containingFile?.let { sourceFileList.add(it) } + if (func is KSFunctionDeclarationImpl) { + val funcName = func.simpleName.asString() + val bodyText = func.ktFunction.let { + it.bodyExpression + ?.text + ?.removePrefix("{") + ?.removeSuffix("}") + ?.trimIndent() ?: it.bodyBlockExpression + ?.statements + ?.joinToString(System.lineSeparator()) { statement -> statement.text } + ?.trimIndent() ?: it.text + } + sourceFile.addProperty( + PropertySpec.builder( + "sourceCodeOf${funcName.first().uppercase()}${funcName.substring(1)}", + String::class + ) + .addModifiers(KModifier.INTERNAL) + .getter( + FunSpec.getterBuilder() + .addStatement("return %S", bodyText) + .build() + ) + .build() + ) + } + } + val file = codeGenerator.createNewFile( + Dependencies(true, *(sourceFileList).toTypedArray()), + packageName, + fileName + ) + OutputStreamWriter(file, StandardCharsets.UTF_8).use(sourceFile.build()::writeTo) + + } + } + } + +} + diff --git a/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/Visitor.kt b/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/Visitor.kt new file mode 100644 index 00000000..8c93a1be --- /dev/null +++ b/gallery-processor/src/jvmMain/kotlin/com/konyaco/fluent/gallery/processor/Visitor.kt @@ -0,0 +1,29 @@ +package com.konyaco.fluent.gallery.processor + +import com.google.devtools.ksp.getDeclaredFunctions +import com.google.devtools.ksp.getDeclaredProperties +import com.google.devtools.ksp.symbol.* + +internal class Visitor( + private val onFunNode:(node: KSFunctionDeclaration) -> Unit, + private val onPropertyNode: (node: KSPropertyDeclaration) -> Unit, +) : KSVisitorVoid() { + + override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) { + super.visitClassDeclaration(classDeclaration, data) + classDeclaration.getDeclaredProperties().forEach { it.accept(this, Unit) } + classDeclaration.getDeclaredFunctions().forEach { it.accept(this, Unit) } + } + + override fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: Unit) { + onFunNode(function) + } + + override fun visitPropertyDeclaration(property: KSPropertyDeclaration, data: Unit) { + super.visitPropertyDeclaration(property, data) + onPropertyNode(property) + } + override fun visitFile(file: KSFile, data: Unit) { + file.declarations.forEach { it.accept(this, Unit) } + } +} \ No newline at end of file diff --git a/gallery-processor/src/jvmMain/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/gallery-processor/src/jvmMain/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 00000000..d229557c --- /dev/null +++ b/gallery-processor/src/jvmMain/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +com.konyaco.fluent.gallery.processor.CommonProcessorProvider \ No newline at end of file diff --git a/gallery/build.gradle.kts b/gallery/build.gradle.kts index 3a4c8bb1..7ce8ed99 100644 --- a/gallery/build.gradle.kts +++ b/gallery/build.gradle.kts @@ -1,24 +1,28 @@ import com.konyaco.fluent.plugin.build.BuildConfig -import org.jetbrains.compose.compose +import com.konyaco.fluent.plugin.build.applyTargets import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose) alias(libs.plugins.android.application) + alias(libs.plugins.ksp) } kotlin { - jvm("desktop") - androidTarget() + applyTargets(publish = false) sourceSets { val commonMain by getting { dependencies { implementation(compose.foundation) + implementation(compose.components.resources) implementation(project(":fluent")) implementation(project(":fluent-icons-extended")) - implementation(compose("org.jetbrains.compose.ui:ui-util")) + implementation(compose.uiUtil) + implementation(libs.highlights) + implementation(project(":source-generated")) } + kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") } val commonTest by getting { dependencies { @@ -27,7 +31,7 @@ kotlin { } val androidMain by getting { dependencies { - implementation("androidx.activity:activity-compose:1.6.1") + implementation(libs.androidx.activity.compose) } } val androidUnitTest by getting @@ -39,12 +43,11 @@ kotlin { val desktopMain by getting { dependencies { implementation(compose.preview) - implementation("com.mayakapps.compose:window-styler:0.3.3-SNAPSHOT") + implementation(libs.window.styler) } } val desktopTest by getting } - jvmToolchain(BuildConfig.Jvm.jvmToolchainVersion) } android { @@ -69,7 +72,7 @@ android { } } - packagingOptions { + packaging { resources { excludes.add("/META-INF/{AL2.0,LGPL2.1}") } @@ -88,6 +91,31 @@ compose.desktop { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "Compose Fluent Design Gallery" packageVersion = "1.0.0" + macOS { + iconFile.set(project.file("icons/icon.icns")) + jvmArgs( + "-Dapple.awt.application.appearance=system" + ) + } + windows { + iconFile.set(project.file("icons/icon.ico")) + } + linux { + iconFile.set(project.file("icons/icon.png")) + } } } +} + +dependencies { + val processor = project(":gallery-processor") + add("kspCommonMainMetadata", processor) +} + +// workaround for KSP only in Common Main. +// https://github.com/google/ksp/issues/567 +tasks.withType>().all { + if (name != "kspCommonMainKotlinMetadata") { + dependsOn("kspCommonMainKotlinMetadata") + } } \ No newline at end of file diff --git a/gallery/icons/icon.icns b/gallery/icons/icon.icns new file mode 100644 index 00000000..0f281e87 Binary files /dev/null and b/gallery/icons/icon.icns differ diff --git a/gallery/icons/icon.ico b/gallery/icons/icon.ico new file mode 100644 index 00000000..af407d05 Binary files /dev/null and b/gallery/icons/icon.ico differ diff --git a/gallery/icons/icon.png b/gallery/icons/icon.png new file mode 100644 index 00000000..b813645f Binary files /dev/null and b/gallery/icons/icon.png differ diff --git a/gallery/src/androidMain/ic_launcher-playstore.png b/gallery/src/androidMain/ic_launcher-playstore.png new file mode 100644 index 00000000..d2d7a0db Binary files /dev/null and b/gallery/src/androidMain/ic_launcher-playstore.png differ diff --git a/gallery/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/gallery/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d11..00000000 --- a/gallery/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/gallery/src/androidMain/res/drawable/ic_launcher_monochrome.xml b/gallery/src/androidMain/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..6f85624a --- /dev/null +++ b/gallery/src/androidMain/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/gallery/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/gallery/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml index eca70cfe..081998b2 100644 --- a/gallery/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/gallery/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,6 @@ - - + + + \ No newline at end of file diff --git a/gallery/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/gallery/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml index eca70cfe..081998b2 100644 --- a/gallery/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/gallery/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,6 @@ - - + + + \ No newline at end of file diff --git a/gallery/src/androidMain/res/mipmap-hdpi/ic_launcher.webp b/gallery/src/androidMain/res/mipmap-hdpi/ic_launcher.webp index c209e78e..b1a3b7be 100644 Binary files a/gallery/src/androidMain/res/mipmap-hdpi/ic_launcher.webp and b/gallery/src/androidMain/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/gallery/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.webp b/gallery/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..09d78f00 Binary files /dev/null and b/gallery/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/gallery/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp b/gallery/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp index b2dfe3d1..b85a3a9e 100644 Binary files a/gallery/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp and b/gallery/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/gallery/src/androidMain/res/mipmap-mdpi/ic_launcher.webp b/gallery/src/androidMain/res/mipmap-mdpi/ic_launcher.webp index 4f0f1d64..e0a790df 100644 Binary files a/gallery/src/androidMain/res/mipmap-mdpi/ic_launcher.webp and b/gallery/src/androidMain/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/gallery/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.webp b/gallery/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..1b6d1cb4 Binary files /dev/null and b/gallery/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/gallery/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp b/gallery/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp index 62b611da..af019b3a 100644 Binary files a/gallery/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp and b/gallery/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/gallery/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp b/gallery/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp index 948a3070..7b517995 100644 Binary files a/gallery/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp and b/gallery/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/gallery/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.webp b/gallery/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..547e7917 Binary files /dev/null and b/gallery/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/gallery/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp b/gallery/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp index 1b9a6956..571cc38f 100644 Binary files a/gallery/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp and b/gallery/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/gallery/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp b/gallery/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp index 28d4b77f..a93f4c19 100644 Binary files a/gallery/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp and b/gallery/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/gallery/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/gallery/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..fc9b432f Binary files /dev/null and b/gallery/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/gallery/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp b/gallery/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp index 9287f508..d34c3c1c 100644 Binary files a/gallery/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp and b/gallery/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/gallery/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp b/gallery/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp index aa7d6427..3245c1e2 100644 Binary files a/gallery/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp and b/gallery/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/gallery/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/gallery/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..763dc37a Binary files /dev/null and b/gallery/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/gallery/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp b/gallery/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp index 9126ae37..c879c0a9 100644 Binary files a/gallery/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/gallery/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/gallery/src/androidMain/res/values/ic_launcher_background.xml b/gallery/src/androidMain/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..605bbb68 --- /dev/null +++ b/gallery/src/androidMain/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FBFFFD + \ No newline at end of file diff --git a/gallery/src/commonMain/composeResources/drawable/banner.png b/gallery/src/commonMain/composeResources/drawable/banner.png new file mode 100644 index 00000000..e88b50df Binary files /dev/null and b/gallery/src/commonMain/composeResources/drawable/banner.png differ diff --git a/gallery/src/commonMain/composeResources/drawable/fluent_logo.xml b/gallery/src/commonMain/composeResources/drawable/fluent_logo.xml new file mode 100644 index 00000000..062ef05e --- /dev/null +++ b/gallery/src/commonMain/composeResources/drawable/fluent_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/gallery/src/commonMain/composeResources/drawable/github_logo.xml b/gallery/src/commonMain/composeResources/drawable/github_logo.xml new file mode 100644 index 00000000..34241eaa --- /dev/null +++ b/gallery/src/commonMain/composeResources/drawable/github_logo.xml @@ -0,0 +1,10 @@ + + + diff --git a/gallery/src/commonMain/composeResources/drawable/icon.png b/gallery/src/commonMain/composeResources/drawable/icon.png new file mode 100644 index 00000000..b813645f Binary files /dev/null and b/gallery/src/commonMain/composeResources/drawable/icon.png differ diff --git a/gallery/src/commonMain/composeResources/drawable/jetpack_compose_logo.png b/gallery/src/commonMain/composeResources/drawable/jetpack_compose_logo.png new file mode 100644 index 00000000..5e4b3ce0 Binary files /dev/null and b/gallery/src/commonMain/composeResources/drawable/jetpack_compose_logo.png differ diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/App.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/App.kt index e4ace65e..52b0e863 100644 --- a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/App.kt +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/App.kt @@ -1,29 +1,48 @@ package com.konyaco.fluent.gallery -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.* +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.vector.ImageVector import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.konyaco.fluent.animation.FluentDuration import com.konyaco.fluent.animation.FluentEasing -import com.konyaco.fluent.component.* -import com.konyaco.fluent.gallery.screen.HomeScreen -import com.konyaco.fluent.gallery.screen.TodoScreen +import com.konyaco.fluent.component.Icon +import com.konyaco.fluent.component.NavigationItemSeparator +import com.konyaco.fluent.component.SideNav +import com.konyaco.fluent.component.SideNavItem +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.component.TextField +import com.konyaco.fluent.gallery.component.ComponentItem +import com.konyaco.fluent.gallery.component.ComponentNavigator +import com.konyaco.fluent.gallery.component.components +import com.konyaco.fluent.gallery.screen.settings.SettingsScreen import com.konyaco.fluent.icons.Icons -import com.konyaco.fluent.icons.regular.* +import com.konyaco.fluent.icons.regular.Settings +import com.konyaco.fluent.surface.Card -@OptIn(ExperimentalAnimationApi::class) @Composable fun App() { - Row(Modifier.fillMaxSize()) { var expanded by remember { mutableStateOf(true) } val (selectedItem, setSelectedItem) = remember { - mutableStateOf(navs.first()) + mutableStateOf(components.first()) } var selectedItemWithContent by remember { mutableStateOf(selectedItem) @@ -36,6 +55,9 @@ fun App() { var textFieldValue by remember { mutableStateOf(TextFieldValue()) } + val navigator = remember(setSelectedItem) { + ComponentNavigator(setSelectedItem) + } SideNav( modifier = Modifier.fillMaxHeight(), expanded = expanded, @@ -45,6 +67,7 @@ fun App() { TextField( value = textFieldValue, onValueChange = { textFieldValue = it }, + placeholder = { Text("Search") }, modifier = Modifier.fillMaxWidth().focusHandle() ) }, @@ -52,285 +75,91 @@ fun App() { NavigationItem(selectedItem, setSelectedItem, settingItem) } ) { - navs.forEach { navItem -> - + components.forEach { navItem -> NavigationItem(selectedItem, setSelectedItem, navItem) - if (navItem.label == "All samples") { + if (navItem.name == "All samples") { NavigationItemSeparator(modifier = Modifier.padding(vertical = 2.dp)) } } } - AnimatedContent(selectedItemWithContent, Modifier.fillMaxHeight().weight(1f), transitionSpec = { - fadeIn(tween(FluentDuration.ShortDuration, easing = FluentEasing.FastInvokeEasing)) + - slideInVertically( - tween( - FluentDuration.ShortDuration, - easing = FluentEasing.FastInvokeEasing - ) - ) { it / 6 } with - fadeOut(tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing)) - }) { - it.content?.invoke() + Card( + modifier = Modifier.fillMaxHeight().weight(1f), + shape = RoundedCornerShape( + topStart = 8.dp, + topEnd = 0.dp, + bottomStart = 0.dp, + bottomEnd = 0.dp + ) + ) { + AnimatedContent(selectedItemWithContent, Modifier.fillMaxSize(), transitionSpec = { + (fadeIn( + tween( + FluentDuration.ShortDuration, + easing = FluentEasing.FadeInFadeOutEasing, + delayMillis = FluentDuration.QuickDuration + ) + ) + slideInVertically( + tween( + FluentDuration.MediumDuration, + easing = FluentEasing.FastInvokeEasing, + delayMillis = FluentDuration.QuickDuration + ) + ) { it / 5 }) togetherWith fadeOut( + tween( + FluentDuration.QuickDuration, + easing = FluentEasing.FadeInFadeOutEasing, + delayMillis = FluentDuration.QuickDuration + ) + ) + }) { + it.content?.invoke(it, navigator) + } } } } @Composable private fun NavigationItem( - selectedItem: NavItem, - onSelectedItemChanged: (NavItem) -> Unit, - navItem: NavItem + selectedItem: ComponentItem, + onSelectedItemChanged: (ComponentItem) -> Unit, + navItem: ComponentItem ) { val expandedItems = remember { mutableStateOf(false) } + LaunchedEffect(selectedItem) { + if (navItem != selectedItem) { + val navItemAsGroup = "${navItem.group}/${navItem.name}/" + if ((selectedItem.group + "/").startsWith(navItemAsGroup)) + expandedItems.value = true + } + } SideNavItem( selectedItem == navItem, onClick = { onSelectedItemChanged(navItem) expandedItems.value = !expandedItems.value }, - icon = navItem.icon?.let { { Icon(it, navItem.label) } }, - content = { Text(navItem.label) }, + icon = navItem.icon?.let { { Icon(it, navItem.name) } }, + content = { Text(navItem.name) }, expandItems = expandedItems.value, - items = navItem.nestedItems?.let { - { - it.forEach { nestedItem -> - NavigationItem( - selectedItem = selectedItem, - onSelectedItemChanged = onSelectedItemChanged, - navItem = nestedItem - ) + items = navItem.items?.let { + if (it.isNotEmpty()) { + { + it.forEach { nestedItem -> + NavigationItem( + selectedItem = selectedItem, + onSelectedItemChanged = onSelectedItemChanged, + navItem = nestedItem + ) + } } + } else { + null } } ) } -private data class NavItem( - val label: String, - val icon: ImageVector? = null, - val nestedItems: List? = null, - val content: (@Composable () -> Unit)? = null, -) - -// TODO Remove unnecessary pages -private val navs = listOf( - NavItem( - label = "Home", - icon = Icons.Default.Home - ) { HomeScreen() }, - NavItem( - label = "Design guidance", - icon = Icons.Default.Ruler, - nestedItems = listOf( - NavItem("Typography", Icons.Default.TextFont) { - TodoScreen() - }, - NavItem("Icons", Icons.Default.Diversity) { - TodoScreen() - }, - NavItem("Colors", Icons.Default.Color) { - TodoScreen() - }, - NavItem( - label = "Accessibility", - icon = Icons.Default.Accessibility, - nestedItems = listOf( - NavItem("Screen reader support") { TodoScreen() }, - NavItem("Keyboard support") { TodoScreen() }, - NavItem("Color contrast") { TodoScreen() } - ) - ) - ) - ), - NavItem("All samples", Icons.Default.AppsList) { TodoScreen() }, - NavItem( - label = "Basic input", - icon = Icons.Default.CheckboxChecked, - nestedItems = listOf( - NavItem("InputValidation") { TodoScreen() }, - NavItem("Button") { TodoScreen() }, - NavItem("DropDownButton") { TodoScreen() }, - NavItem("HyperlinkButton") { TodoScreen() }, - NavItem("RepeatButton") { TodoScreen() }, - NavItem("ToggleButton") { TodoScreen() }, - NavItem("SplitButton") { TodoScreen() }, - NavItem("CheckBox") { TodoScreen() }, - NavItem("ColorPicker") { TodoScreen() }, - NavItem("ComboBox") { TodoScreen() }, - NavItem("RadioButton") { TodoScreen() }, - NavItem("RatingControl") { TodoScreen() }, - NavItem("Slider") { TodoScreen() }, - NavItem("ToggleSwitch") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "Collections", - icon = Icons.Default.Table, - nestedItems = listOf( - NavItem("FlipView") { TodoScreen() }, - NavItem("GridView") { TodoScreen() }, - NavItem("ListBox") { TodoScreen() }, - NavItem("ListView") { TodoScreen() }, - NavItem("PullToRefresh") { TodoScreen() }, - NavItem("TreeView") { TodoScreen() }, - NavItem("DataGrid") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "Date & time", - icon = Icons.Default.CalendarClock, - nestedItems = listOf( - NavItem("CalendarDatePicker") { TodoScreen() }, - NavItem("CalendarView") { TodoScreen() }, - NavItem("DatePicker") { TodoScreen() }, - NavItem("TimePicker") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "Dialogs & flyouts", - icon = Icons.Default.Chat, - nestedItems = listOf( - NavItem("ContentDialog") { TodoScreen() }, - NavItem("Flyout") { TodoScreen() }, - NavItem("TeachingTip") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "Layout", - icon = Icons.Default.SlideLayout, - nestedItems = listOf( - NavItem("Border") { TodoScreen() }, - NavItem("Canvas") { TodoScreen() }, - NavItem("Expander") { TodoScreen() }, - NavItem("ItemRepeater") { TodoScreen() }, - NavItem("Grid") { TodoScreen() }, - NavItem("RadioButtons") { TodoScreen() }, - NavItem("RelativePanel") { TodoScreen() }, - NavItem("SpiltView") { TodoScreen() }, - NavItem("StackPanel") { TodoScreen() }, - NavItem("VariableSizedWrapGrid") { TodoScreen() }, - NavItem("Viewbox") { TodoScreen() }, - ) - ) { TodoScreen() }, - NavItem( - label = "Media", - icon = Icons.Default.VideoClip, - nestedItems = listOf( - NavItem("AnimatedVisualPlayer") { TodoScreen() }, - NavItem("Image") { TodoScreen() }, - NavItem("MediaPlayerElement") { TodoScreen() }, - NavItem("PersonPicture") { TodoScreen() }, - NavItem("Sound") { TodoScreen() }, - NavItem("Webview") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "Menus & toolbars", - icon = Icons.Default.Save, - nestedItems = listOf( - NavItem("BasicUICommand") { TodoScreen() }, - NavItem("StandardUICommand") { TodoScreen() }, - NavItem("AppBarButton") { TodoScreen() }, - NavItem("AppBarSeparator") { TodoScreen() }, - NavItem("AppBarToggleButton") { TodoScreen() }, - NavItem("CommandBar") { TodoScreen() }, - NavItem("MenuBar") { TodoScreen() }, - NavItem("CommandBarFlyout") { TodoScreen() }, - NavItem("MenuFlyout") { TodoScreen() }, - NavItem("SwipeControl") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "Motion", - icon = Icons.Default.Flash, - nestedItems = listOf( - NavItem("Connected Animation") { TodoScreen() }, - NavItem("Easing Functions") { TodoScreen() }, - NavItem("Page Transitions") { TodoScreen() }, - NavItem("Theme Transitions") { TodoScreen() }, - NavItem("Animation interop") { TodoScreen() }, - NavItem("Implicit Transitions") { TodoScreen() }, - NavItem("ParallaxView") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "Navigation", - icon = Icons.Default.Navigation, - nestedItems = listOf( - NavItem("BreadcrumbBar") { TodoScreen() }, - NavItem("NavigationViw") { TodoScreen() }, - NavItem("Pivot") { TodoScreen() }, - NavItem("TabView") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "Scrolling", - icon = Icons.Default.ArrowSort, - nestedItems = listOf( - NavItem("PipsPager") { TodoScreen() }, - NavItem("ScrollViewer") { TodoScreen() }, - NavItem("SemanticZoom") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "Status & info", - icon = Icons.Default.ChatMultiple, - nestedItems = listOf( - NavItem("InfoBadge") { TodoScreen() }, - NavItem("InfoBar") { TodoScreen() }, - NavItem("ProgressBar") { TodoScreen() }, - NavItem("ProgressRing") { TodoScreen() }, - NavItem("Tooltip") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "Styles", - icon = Icons.Default.Color, - nestedItems = listOf( - NavItem("AcrylicBrush") { TodoScreen() }, - NavItem("AnimatedIcon") { TodoScreen() }, - NavItem("ColorPaletteResources") { TodoScreen() }, - NavItem("Compact Sizing") { TodoScreen() }, - NavItem("IconElement") { TodoScreen() }, - NavItem("RadialGradientBrush") { TodoScreen() }, - NavItem("Reveal Focus") { TodoScreen() }, - NavItem("Shape") { TodoScreen() }, - NavItem("Line") { TodoScreen() }, - NavItem("System Backdrops(Mica/Acrylic)") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "System", - icon = Icons.Default.System, - nestedItems = listOf( - NavItem("Clipboard") { TodoScreen() }, - NavItem("FilePicker") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "Text", - icon = Icons.Default.TextFont, - nestedItems = listOf( - NavItem("AutoSuggestBox") { TodoScreen() }, - NavItem("NumberBox") { TodoScreen() }, - NavItem("PasswordBox") { TodoScreen() }, - NavItem("RichEditBox") { TodoScreen() }, - NavItem("RichTextBlock") { TodoScreen() }, - NavItem("TextBlock") { TodoScreen() }, - NavItem("TextBox") { TodoScreen() } - ) - ) { TodoScreen() }, - NavItem( - label = "Windowing", - icon = Icons.Default.Window, - nestedItems = listOf( - NavItem("Multiple windows") { TodoScreen() }, - NavItem("TitleBar") { TodoScreen() } - ) - ) { TodoScreen() }, -) - -private val settingItem = NavItem("Settings", Icons.Default.Settings) { TodoScreen() } \ No newline at end of file +private val settingItem = ComponentItem("Settings", group = "", description = "", icon = Icons.Default.Settings) { SettingsScreen() } \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/GalleryTheme.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/GalleryTheme.kt index 5883dd2e..f5c5bf90 100644 --- a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/GalleryTheme.kt +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/GalleryTheme.kt @@ -4,20 +4,24 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.LocalContentColor +import com.konyaco.fluent.* import com.konyaco.fluent.background.Mica -import com.konyaco.fluent.darkColors -import com.konyaco.fluent.lightColors val LocalStore = compositionLocalOf { error("Not provided") } class Store( - systemDarkMode: Boolean + systemDarkMode: Boolean, + enabledAcrylicPopup: Boolean, + compactMode: Boolean ) { var darkMode by mutableStateOf(systemDarkMode) + + var enabledAcrylicPopup by mutableStateOf(enabledAcrylicPopup) + + var compactMode by mutableStateOf(compactMode) } +@OptIn(ExperimentalFluentApi::class) @Composable fun GalleryTheme( displayMicaLayer: Boolean = true, @@ -25,7 +29,13 @@ fun GalleryTheme( ) { val systemDarkMode = isSystemInDarkTheme() - val store = remember { Store(systemDarkMode) } + val store = remember { + Store( + systemDarkMode = systemDarkMode, + enabledAcrylicPopup = true, + compactMode = true + ) + } LaunchedEffect(systemDarkMode) { store.darkMode = systemDarkMode @@ -33,7 +43,11 @@ fun GalleryTheme( CompositionLocalProvider( LocalStore provides store ) { - FluentTheme(colors = if (store.darkMode) darkColors() else lightColors()) { + FluentTheme( + colors = if (store.darkMode) darkColors() else lightColors(), + useAcrylicPopup = store.enabledAcrylicPopup, + compactMode = store.compactMode + ) { if (displayMicaLayer) { Mica(modifier = Modifier.fillMaxSize()) { content() diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/ProjectUrl.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/ProjectUrl.kt new file mode 100644 index 00000000..6219dc8b --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/ProjectUrl.kt @@ -0,0 +1,28 @@ +package com.konyaco.fluent.gallery + +object ProjectUrl { + + const val ROOT = "https://github.com/Konyaco/compose-fluent-ui" + + const val FRAMEWORK = "https://developer.android.com/develop/ui/compose" + + const val UI_DESIGN = "https://fluent2.microsoft.design/" + + const val FEED_BACK = "$ROOT/issues/new/choose" + + private const val BRANCH = "master" + + fun componentCodeOf(path: String): String { + return "$ROOT/tree/$BRANCH/$path" + } + + fun galleryCodeOf(path: String): String { + return "$ROOT/tree/$BRANCH/gallery/src/$path" + } + + //TODO documentation redirection + fun documentationOf(path: String): String { + return ROOT + } + +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/annotation/Component.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/annotation/Component.kt new file mode 100644 index 00000000..84a8e8ea --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/annotation/Component.kt @@ -0,0 +1,11 @@ +package com.konyaco.fluent.gallery.annotation + +@Target(AnnotationTarget.FUNCTION) +annotation class Component( + val name: String = "", + val icon: String = "", + val description: String = "", + val group: String = "_Auto", + val index: Int = -1, + val enabled: Boolean = true +) \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/annotation/ComponentGroup.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/annotation/ComponentGroup.kt new file mode 100644 index 00000000..4f845549 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/annotation/ComponentGroup.kt @@ -0,0 +1,9 @@ +package com.konyaco.fluent.gallery.annotation + +@Target(AnnotationTarget.PROPERTY) +annotation class ComponentGroup( + val icon: String, + val index: Int = Int.MAX_VALUE, + val generateScreen: Boolean = true, + val packageMap: String = "" +) diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/annotation/Sample.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/annotation/Sample.kt new file mode 100644 index 00000000..14868117 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/annotation/Sample.kt @@ -0,0 +1,4 @@ +package com.konyaco.fluent.gallery.annotation + +@Target(AnnotationTarget.FUNCTION) +annotation class Sample() diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentGroupInfo.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentGroupInfo.kt new file mode 100644 index 00000000..26c11743 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentGroupInfo.kt @@ -0,0 +1,61 @@ +@file:Suppress + +package com.konyaco.fluent.gallery.component + +import com.konyaco.fluent.gallery.annotation.ComponentGroup + +object ComponentGroupInfo { + + private const val screenPackage: String = "com.konyaco.fluent.gallery.screen" + + @ComponentGroup("Ruler", index = 0, generateScreen = false, packageMap = "$screenPackage.design") + const val DesignGuidance = "Design guidance" + + @ComponentGroup("Accessibility", generateScreen = false, index = 3) + const val Accessibility = "Design guidance/Accessibility" + + @ComponentGroup("CheckboxChecked", index = 2, packageMap = "$screenPackage.basicinput") + const val BasicInput = "Basic input" + + @ComponentGroup("Table", index = 3, packageMap = "$screenPackage.collections") + const val Collections = "Collections" + + @ComponentGroup("CalendarClock", index = 4, packageMap = "$screenPackage.datetime") + const val DateAndTime = "Date & time" + + @ComponentGroup("Chat", index = 5, packageMap = "$screenPackage.dialogs") + const val DialogAndFlyout = "Dialog & flyouts" + + @ComponentGroup("SlideLayout", index = 6) + const val Layout = "Layout" + + @ComponentGroup("VideoClip", index = 7) + const val Media = "Media" + + @ComponentGroup("Save", index = 8) + const val MenusAndToolbars = "Menus & toolbars" + + @ComponentGroup("Flash", index = 9) + const val Motion = "Motion" + + @ComponentGroup("Navigation", index = 10) + const val Navigation = "Navigation" + + @ComponentGroup("ArrowSort", index = 11) + const val Scrolling = "Scrolling" + + @ComponentGroup("ChatMultiple", index = 12, packageMap = "$screenPackage.status") + const val StatusAndInfo = "Status & info" + + @ComponentGroup("Color", index = 13, packageMap = "$screenPackage.styles") + const val Styles = "Styles" + + @ComponentGroup("System", index = 14) + const val System = "System" + + @ComponentGroup("TextFont", index = 15, packageMap = "$screenPackage.text") + const val Text = "Text" + + @ComponentGroup("Window", index = 16) + const val Windowing = "Windowing" +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentIndexScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentIndexScreen.kt new file mode 100644 index 00000000..dbb757ac --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentIndexScreen.kt @@ -0,0 +1,69 @@ +package com.konyaco.fluent.gallery.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.surface.Card + +@Composable +fun ComponentItem.ComponentIndexScreen(navigator: ComponentNavigator) { + ComponentIndexScreen(name, items, navigator) +} + +@Composable +fun ComponentIndexScreen( + name: String, + items: List?, + navigator: ComponentNavigator +) { + Column { + GalleryHeader( + title = name, + description = "", + controlVisible = false + ) + LazyVerticalGrid( + columns = GridCells.Adaptive(300.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(32.dp) + ) { + items( + items = items ?: emptyList() + ) { + Card(onClick = { navigator.navigate(it) }) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(16.dp) + .fillMaxWidth() + .heightIn(64.dp) + ) { + Text( + text = it.name, + style = FluentTheme.typography.bodyStrong + ) + Text( + text = it.description, + style = FluentTheme.typography.caption, + fontWeight = FontWeight.Normal, + minLines = 2, + maxLines = 2 + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentItem.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentItem.kt new file mode 100644 index 00000000..36e652d5 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentItem.kt @@ -0,0 +1,13 @@ +package com.konyaco.fluent.gallery.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector + +class ComponentItem( + val name: String = "", + val group: String, + val description: String, + val items: List? = null, + val icon: ImageVector? = null, + val content: (@Composable ComponentItem.(navigator: ComponentNavigator) -> Unit)? +) diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentNavigator.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentNavigator.kt new file mode 100644 index 00000000..0f8953c7 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentNavigator.kt @@ -0,0 +1,6 @@ +package com.konyaco.fluent.gallery.component + +fun interface ComponentNavigator { + fun navigate(componentItem: ComponentItem) + +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/CopyButton.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/CopyButton.kt new file mode 100644 index 00000000..b76d693f --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/CopyButton.kt @@ -0,0 +1,50 @@ +package com.konyaco.fluent.gallery.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import com.konyaco.fluent.component.Button +import com.konyaco.fluent.component.Icon +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.regular.Checkmark +import com.konyaco.fluent.icons.regular.Copy +import kotlinx.coroutines.delay + +@Composable +fun CopyButton( + copyData: String, + modifier: Modifier = Modifier +) { + var isCopy by remember { mutableStateOf(false) } + LaunchedEffect(isCopy) { + if (isCopy) { + delay(1000) + isCopy = false + } + } + val clipboard = LocalClipboardManager.current + Button( + onClick = { + clipboard.setText(AnnotatedString(copyData)) + isCopy = true + }, + iconOnly = true, + content = { + AnimatedContent(isCopy) { target -> + if (target) { + Icon(Icons.Default.Checkmark, contentDescription = null) + } else { + Icon(Icons.Default.Copy, contentDescription = null) + } + } + }, + modifier = modifier + ) +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/GalleryHeader.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/GalleryHeader.kt new file mode 100644 index 00000000..2e65da3d --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/GalleryHeader.kt @@ -0,0 +1,216 @@ +package com.konyaco.fluent.gallery.component + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.ClickableText +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.LocalContentAlpha +import com.konyaco.fluent.LocalContentColor +import com.konyaco.fluent.LocalTextStyle +import com.konyaco.fluent.component.Button +import com.konyaco.fluent.component.DropDownButton +import com.konyaco.fluent.component.FlyoutPlacement +import com.konyaco.fluent.component.HyperlinkButton +import com.konyaco.fluent.component.Icon +import com.konyaco.fluent.component.MenuFlyoutContainer +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.component.ToggleButton +import com.konyaco.fluent.gallery.ProjectUrl +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.filled.BrightnessHigh +import com.konyaco.fluent.icons.regular.Document +import com.konyaco.fluent.icons.regular.PersonFeedback +import fluentdesign.gallery.generated.resources.Res +import fluentdesign.gallery.generated.resources.github_logo +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.painterResource + +@Composable +fun GalleryHeader( + title: String, + description: String, + documentationPath: String? = null, + componentPath: String? = null, + galleryPath: String? = null, + controlVisible: Boolean = true, + themeButtonChecked: Boolean = false, + onThemeButtonChanged: (Boolean) -> Unit = {} +) { + GalleryHeader( + AnnotatedString(title), + AnnotatedString(description), + documentationPath, + componentPath, + galleryPath, + controlVisible, + themeButtonChecked, + onThemeButtonChanged + ) +} + +@OptIn(ExperimentalResourceApi::class) +@Composable +fun GalleryHeader( + title: AnnotatedString, + description: AnnotatedString, + documentPath: String? = null, + componentPath: String? = null, + galleryPath: String? = null, + themeButtonChecked: Boolean = false, + controlVisible: Boolean = true, + onThemeButtonChanged: (Boolean) -> Unit = {} +) { + Column(Modifier.padding(top = 32.dp, bottom = 24.dp, start = 32.dp, end = 32.dp)) { + val uriHandler = LocalUriHandler.current + Text( + text = title, + style = FluentTheme.typography.title + ) + + if (controlVisible || documentPath != null || componentPath != null || galleryPath != null) { + Row( + modifier = Modifier.padding(top = 12.dp).height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (documentPath != null) { + Button( + onClick = { + uriHandler.openUri(ProjectUrl.documentationOf(documentPath)) + }, + content = { + Icon( + Icons.Default.Document, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Text("Documentation") + } + ) + } + if (componentPath != null && galleryPath != null) { + MenuFlyoutContainer( + flyout = { + HyperlinkButton( + onClick = { + uriHandler.openUri( + ProjectUrl.componentCodeOf(componentPath) + ) + isFlyoutVisible = false + }, + content = { Text("Component") }, + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 5.dp, vertical = 2.dp) + ) + HyperlinkButton( + onClick = { + uriHandler.openUri(ProjectUrl.galleryCodeOf(galleryPath)) + isFlyoutVisible = false + }, + content = { Text("Sample") }, + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 5.dp, vertical = 2.dp) + ) + }, + content = { + DropDownButton( + onClick = { isFlyoutVisible = true }, + content = { + Icon( + painter = painterResource(Res.drawable.github_logo), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Text("Source") + } + ) + }, + placement = FlyoutPlacement.Bottom, + adaptivePlacement = true + ) + } + Spacer(modifier = Modifier.weight(1f)) + if (controlVisible) { + ToggleButton( + checked = themeButtonChecked, + onCheckedChanged = onThemeButtonChanged, + content = { + Icon( + Icons.Filled.BrightnessHigh, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + }, + iconOnly = true, + modifier = Modifier.widthIn(40.dp) + ) + Spacer( + modifier = Modifier.padding(2.dp).height(16.dp).width(1.dp) + .background(FluentTheme.colors.stroke.divider.default) + ) + Button( + onClick = { uriHandler.openUri(ProjectUrl.FEED_BACK) }, + content = { + Icon( + Icons.Default.PersonFeedback, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + }, + iconOnly = true, + modifier = Modifier.widthIn(40.dp) + ) + } + } + } + + if (description.isNotBlank()) { + GalleryDescription(title, Modifier.padding(top = 24.dp)) + } + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalTextApi::class) +@Composable +fun GalleryDescription(description: AnnotatedString, modifier: Modifier = Modifier) { + var isOnHoverLink by remember { + mutableStateOf(false) + } + val uriHandler = LocalUriHandler.current + ClickableText( + text = description, + onHover = { + val index = it ?: return@ClickableText + isOnHoverLink = + description.getUrlAnnotations(index, index + 1).firstOrNull() != null + }, + style = LocalTextStyle.current.copy(LocalContentColor.current.copy(alpha = LocalContentAlpha.current)), + modifier = modifier.pointerHoverIcon( + icon = if (isOnHoverLink) PointerIcon.Hand else PointerIcon.Default + ) + ) { + description.getUrlAnnotations(it, it + 1).firstOrNull()?.let { urlAnnotation -> + uriHandler.openUri(urlAnnotation.item.url) + } + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/GalleryPage.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/GalleryPage.kt new file mode 100644 index 00000000..f7ce6479 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/GalleryPage.kt @@ -0,0 +1,125 @@ +package com.konyaco.fluent.gallery.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.Colors +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.component.ScrollbarContainer +import com.konyaco.fluent.component.rememberScrollbarAdapter +import com.konyaco.fluent.darkColors +import com.konyaco.fluent.gallery.LocalStore +import com.konyaco.fluent.lightColors + +@Composable +fun GalleryPage( + title: String, + description: String, + documentPath: String? = null, + componentPath: String? = null, + galleryPath: String? = null, + content: @Composable GalleryPageScope.() -> Unit +) { + GalleryPage( + AnnotatedString(title), + AnnotatedString(description), + documentPath, + componentPath, + galleryPath, + content + ) +} + +@Composable +fun GalleryPage( + title: AnnotatedString, + description: AnnotatedString, + documentPath: String? = null, + componentPath: String? = null, + galleryPath: String? = null, + content: @Composable GalleryPageScope.() -> Unit +) { + + Column( + modifier = Modifier.fillMaxSize() + ) { + val inverseTheme = remember { mutableStateOf(false) } + GalleryHeader( + title, + AnnotatedString(""), + documentPath, + componentPath, + galleryPath, + inverseTheme.value + ) { inverseTheme.value = !inverseTheme.value } + + val scrollState = rememberScrollState() + ScrollbarContainer( + adapter = rememberScrollbarAdapter(scrollState), + modifier = Modifier.weight(1f) + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .verticalScroll(scrollState) + .padding(start = 32.dp, end = 32.dp, top = 0.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + val scope = remember { GalleryPageScope(this) { inverseTheme.value } } + GalleryDescription(description) + scope.content() + } + } + } +} + +class GalleryPageScope(columnScope: ColumnScope, private val inverseTheme: () -> Boolean) : + ColumnScope by columnScope { + + @Composable + fun Section( + title: String, + sourceCode: String, + options: (@Composable ColumnScope.() -> Unit)? = null, + output: (@Composable ColumnScope.() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit + ) { + Section { + GallerySection( + modifier = Modifier.fillMaxSize(), + title = title, + sourceCode = sourceCode, + options = options, + content = content, + output = output, + colors = it + ) + } + } + + @Composable + fun Section(content: @Composable (colors: Colors) -> Unit) { + val currentThemeMode = LocalStore.current.darkMode + val inverseThemeMode = inverseTheme() + content( + when { + inverseThemeMode && currentThemeMode -> lightColors() + inverseThemeMode && !currentThemeMode -> darkColors() + else -> FluentTheme.colors + } + ) + } + +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/GallerySection.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/GallerySection.kt new file mode 100644 index 00000000..dff09b33 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/GallerySection.kt @@ -0,0 +1,187 @@ +package com.konyaco.fluent.gallery.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.Colors +import com.konyaco.fluent.ExperimentalFluentApi +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.FluentThemeConfiguration +import com.konyaco.fluent.animation.FluentDuration +import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.BackgroundSizing +import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.component.Icon +import com.konyaco.fluent.component.Scrollbar +import com.konyaco.fluent.component.SubtleButton +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.regular.ChevronDown +import com.konyaco.fluent.surface.Card + +@OptIn(ExperimentalFluentApi::class) +@Composable +fun GallerySection( + modifier: Modifier, + title: String, + sourceCode: String, + colors: Colors = FluentTheme.colors, + output: (@Composable ColumnScope.() -> Unit)? = null, + options: (@Composable ColumnScope.() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit, +) { + Column(modifier) { + Text(title, style = FluentTheme.typography.bodyStrong) + Spacer(Modifier.height(16.dp)) + FluentThemeConfiguration(colors = colors) { + Layer( + modifier = Modifier.fillMaxWidth().wrapContentHeight(), + shape = RoundedCornerShape( + topStart = 8.dp, topEnd = 8.dp + ), + color = FluentTheme.colors.background.solid.base, + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) { + Row( + modifier = Modifier.height(IntrinsicSize.Max) + ) { + Box( + modifier = Modifier.weight(1f) + .defaultMinSize(minHeight = 100.dp) + .padding(16.dp), + contentAlignment = Alignment.CenterStart, + content = content + ) + if (output != null) { + Box( + modifier = Modifier.fillMaxHeight() + .padding(0.dp, 12.dp, 12.dp, 12.dp) + .padding(16.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Output:") + output() + } + } + } + if (options != null) { + Spacer( + modifier = Modifier.padding(vertical = 1.dp) + .fillMaxHeight() + .width(1.dp) + .background(FluentTheme.colors.stroke.divider.default) + ) + Column( + modifier = Modifier.fillMaxHeight() + .background(FluentTheme.colors.background.card.default) + .padding(16.dp) + .width(IntrinsicSize.Max), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + options() + } + } + } + } + } + + var sourceCodeExpanded by remember { mutableStateOf(false) } + + val interactionSource = remember { MutableInteractionSource() } + Card( + modifier = Modifier.fillMaxWidth() + .clickable(interactionSource = interactionSource, indication = null, onClick = { + sourceCodeExpanded = !sourceCodeExpanded + }), + shape = RoundedCornerShape( + bottomEnd = if (sourceCodeExpanded) 0.dp else 8.dp, + bottomStart = if (sourceCodeExpanded) 0.dp else 8.dp + ) + ) { + Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(modifier = Modifier.padding(start = 8.dp).weight(1f), text = "Source Code") + SubtleButton( + onClick = { sourceCodeExpanded = !sourceCodeExpanded }, + interaction = interactionSource, + iconOnly = true + ) { + Icon( + modifier = Modifier.rotate( + animateFloatAsState( + if (sourceCodeExpanded) 180f else 0f, + ).value + ), + imageVector = Icons.Default.ChevronDown, + contentDescription = "Expand source code" + ) + } + } + } + AnimatedVisibility( + visible = sourceCodeExpanded, + enter = expandVertically( + tween(FluentDuration.MediumDuration, 0, FluentEasing.FastInvokeEasing) + ), + exit = shrinkVertically( + tween(FluentDuration.QuickDuration, 0, FluentEasing.SoftDismissEasing) + ) + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape( + bottomEnd = 8.dp, + bottomStart = 8.dp + ) + ) { + Column(Modifier.padding(16.dp, 12.dp)) { + Text("Kotlin", style = FluentTheme.typography.bodyStrong) + Spacer(Modifier.height(12.dp)) + val scrollState = rememberScrollState() + Box(Modifier.fillMaxWidth().wrapContentHeight()) { + SourceCode( + modifier = Modifier.horizontalScroll(scrollState), + code = sourceCode + ) + Box(Modifier.fillMaxWidth().align(Alignment.BottomCenter)) { + Scrollbar(modifier = Modifier.fillMaxWidth(), isVertical = false, adapter = com.konyaco.fluent.component.rememberScrollbarAdapter(scrollState)) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/SourceCode.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/SourceCode.kt new file mode 100644 index 00000000..8ef2d487 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/SourceCode.kt @@ -0,0 +1,58 @@ +package com.konyaco.fluent.gallery.component + +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.LocalStore +import dev.snipme.highlights.Highlights +import dev.snipme.highlights.model.BoldHighlight +import dev.snipme.highlights.model.ColorHighlight +import dev.snipme.highlights.model.SyntaxLanguage +import dev.snipme.highlights.model.SyntaxThemes + +@Composable +fun SourceCode(code: String, language: SyntaxLanguage = SyntaxLanguage.KOTLIN, modifier: Modifier = Modifier) { + SelectionContainer { + val isDark = LocalStore.current.darkMode + val highLights = remember(code, language, isDark) { + Highlights.Builder() + .code(code) + .language(language) + .theme(SyntaxThemes.default(isDark)) + .build() + .getHighlights() + } + val codeAnnotatedString = buildAnnotatedString { + append(code) + highLights.forEach { + when(it) { + is ColorHighlight -> addStyle( + style = SpanStyle(color = Color(it.rgb).copy(1f)), + start = it.location.start, + end = it.location.end + ) + is BoldHighlight -> addStyle( + style = SpanStyle(fontWeight = FontWeight.Bold), + start = it.location.start, + end = it.location.end + ) + } + } + + } + Text( + modifier = modifier, + text = codeAnnotatedString, + fontFamily = FontFamily.Monospace, + overflow = TextOverflow.Visible + ) + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/TodoComponent.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/TodoComponent.kt new file mode 100644 index 00000000..d84bba7f --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/TodoComponent.kt @@ -0,0 +1,26 @@ +package com.konyaco.fluent.gallery.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import com.konyaco.fluent.component.Button +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.ProjectUrl + +@Composable +fun TodoComponent() { + Box(Modifier.fillMaxWidth()) { + Text(modifier = Modifier.align(Alignment.CenterStart), text = "TODO") + val urlHandle = LocalUriHandler.current + Button( + modifier = Modifier.align(Alignment.CenterEnd), + onClick = { + urlHandle.openUri(ProjectUrl.ROOT) + }) { + Text("Contribute") + } + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/AllSamplesScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/AllSamplesScreen.kt new file mode 100644 index 00000000..2cec68ed --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/AllSamplesScreen.kt @@ -0,0 +1,29 @@ +package com.konyaco.fluent.gallery.screen + +import androidx.compose.runtime.* +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.component.* +import com.konyaco.fluent.gallery.component._AllSamplesScreenComponent +import com.konyaco.fluent.gallery.component._HomeScreenComponent + +@Component(icon = "AppsList", index = 1, name = "All samples") +@Composable +fun AllSamplesScreen(navigator: ComponentNavigator) { + var allComponents by remember { + mutableStateOf?>(null) + } + LaunchedEffect(flatMapComponents) { + val excludeComponents = listOf( + _HomeScreenComponent, + _AllSamplesScreenComponent, + Design_guidance_TypographyScreenComponent, + Design_guidance_IconsScreenComponent + ) + allComponents = flatMapComponents.filter { it !in excludeComponents } + } + ComponentIndexScreen( + name = "All samples", + items = allComponents, + navigator + ) +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/HomeScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/HomeScreen.kt index 355c3aba..c5b83286 100644 --- a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/HomeScreen.kt +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/HomeScreen.kt @@ -1,362 +1,216 @@ package com.konyaco.fluent.gallery.screen -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.Density +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.konyaco.fluent.FluentTheme -import com.konyaco.fluent.LocalContentColor -import com.konyaco.fluent.background.Layer -import com.konyaco.fluent.component.* +import com.konyaco.fluent.component.Icon +import com.konyaco.fluent.component.Text import com.konyaco.fluent.gallery.LocalStore +import com.konyaco.fluent.gallery.ProjectUrl +import com.konyaco.fluent.gallery.annotation.Component import com.konyaco.fluent.icons.Icons -import com.konyaco.fluent.icons.regular.* - - +import com.konyaco.fluent.icons.regular.Open +import com.konyaco.fluent.surface.Card +import fluentdesign.gallery.generated.resources.Res +import fluentdesign.gallery.generated.resources.banner +import fluentdesign.gallery.generated.resources.fluent_logo +import fluentdesign.gallery.generated.resources.github_logo +import fluentdesign.gallery.generated.resources.jetpack_compose_logo +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.painterResource + +@OptIn(ExperimentalResourceApi::class) +@Component(icon = "Home") @Composable fun HomeScreen() { - var displayDialog by remember { mutableStateOf(false) } - val density = LocalDensity.current - var scale by remember(density) { mutableStateOf(density.density) } - val store = LocalStore.current - - Layer( - modifier = Modifier.padding(top = 16.dp).fillMaxSize() - .verticalScroll(rememberScrollState()), - shape = RoundedCornerShape(8.dp), - cornerRadius = 8.dp, - outsideBorder = true + val uriHandler = LocalUriHandler.current + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Column(Modifier.padding(16.dp), Arrangement.spacedBy(8.dp)) { - Controller(scale, { scale = it }, store.darkMode, { store.darkMode = it }) - - CompositionLocalProvider(LocalDensity provides Density(scale)) { - Content() - } - - AccentButton(onClick = { - displayDialog = true - }) { Text("Display Dialog") } - - Box { - var expanded by remember { mutableStateOf(false) } - - Button(onClick = { - expanded = true - }) { - Text("Show DropdownMenu") - } - - fun close() { - expanded = false - } + val gradient = if (LocalStore.current.darkMode) { + Brush.linearGradient( + colors = listOf( + Color(0xff1A212C), + Color(0xff2C343C), + ), + start = Offset.Zero, + end = Offset.Infinite + ) - DropdownMenu(expanded, ::close) { - DropdownMenuItem(::close) { Text("Option 1") } - DropdownMenuItem(::close) { Text("Option 2") } - DropdownMenuItem(::close) { Text("Option 3") } - } - } - var currentPlacement by remember { - mutableStateOf(FlyoutPlacement.Auto) - } - Row { + } else { + Brush.linearGradient( + colors = listOf( + Color(0xffCCD7E8), + Color(0xffDAE9F7), + ), + start = Offset.Zero, + end = Offset.Infinite + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(256.dp) + .border(1.dp, FluentTheme.colors.stroke.card.default, shape = RoundedCornerShape(4.dp)) + .clip(RoundedCornerShape(4.dp)) + .background(gradient) + ) { + Image( + painter = painterResource(Res.drawable.banner), + contentDescription = null, + modifier = Modifier.scale(2.05f) + ) + Text( + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + text = "Compose\nFluent Design", + style = FluentTheme.typography.titleLarge, + textAlign = TextAlign.End, + color = FluentTheme.colors.text.text.primary + ) + } - FlyoutContainer( - flyout = { - Text("this is a flyout") - }, - placement = currentPlacement, - content = { - Button( - onClick = { isFlyoutVisible = true } - ) { - Text("Open Flyout") - } - } + Card( + onClick = { + uriHandler.openUri(ProjectUrl.FRAMEWORK) + }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(Res.drawable.jetpack_compose_logo), + contentDescription = null, + modifier = Modifier.size(64.dp) ) - Spacer(Modifier.width(8.dp)) - Box { - var isFlyoutPlacementDropdownMenuOpened by remember { - mutableStateOf(false) - } - Button(onClick = { - isFlyoutPlacementDropdownMenuOpened = true - }) { - Text("Flyout placement") - } - val item = @Composable { placement: FlyoutPlacement -> - DropdownMenuItem({ - currentPlacement = placement - isFlyoutPlacementDropdownMenuOpened = false - }) { - Icon( - Icons.Default.Checkmark, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp) - .alpha(if (placement == currentPlacement) 1f else 0f) - ) - Text(text = placement.toString()) - } - } - DropdownMenu( - isFlyoutPlacementDropdownMenuOpened, - { isFlyoutPlacementDropdownMenuOpened = false }) { - FlyoutPlacement.entries.forEach { item(it) } - } + Column( + modifier = Modifier.weight(1f).padding(start = 16.dp) + ) { + Text( + text = "Jetpack Compose", + style = FluentTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "A powerful toolkit to build beautiful UI on multiple platforms.", + style = FluentTheme.typography.body, + color = FluentTheme.colors.text.text.secondary + ) } + Icon( + Icons.Default.Open, + contentDescription = "Open Link", + Modifier.padding(end = 16.dp), + tint = FluentTheme.colors.text.text.secondary + ) } + } - MenuFlyoutContainer( - placement = currentPlacement, - flyout = { - MenuFlyoutItem( - onClick = { - - }, - icon = { - Icon(Icons.Default.Delete, contentDescription = null) - }, - text = { - Text("Delete") - } - ) - MenuFlyoutSeparator() - MenuFlyoutItem( - onClick = { - - }, - icon = { - Icon(Icons.Default.Add, contentDescription = null) - }, - text = { - Text("Add") - } - ) - MenuFlyoutSeparator() - MenuFlyoutItem( - onClick = {}, - icon = {}, - paddingIcon = true, - text = { Text("test") } + Card( + onClick = { + uriHandler.openUri(ProjectUrl.UI_DESIGN) + }, + modifier = Modifier.fillMaxWidth() + + ) { + Row( + Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(Res.drawable.fluent_logo), + contentDescription = null, + modifier = Modifier.size(64.dp).padding(8.dp), + tint = FluentTheme.colors.text.text.primary + ) + Column( + modifier = Modifier.weight(1f).padding(start = 16.dp) + ) { + Text( + text = "Fluent Design", + style = FluentTheme.typography.bodyLarge ) - MenuFlyoutItem( - items = { - MenuFlyoutItem( - onClick = { - - }, - icon = { - Icon(Icons.Default.Add, contentDescription = null) - }, - text = { - Text("Add") - } - ) - }, - icon = { - Icon(Icons.Default.ClipboardMore, contentDescription = null) - }, - text = { - Text("More") - } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "A modern design system face to desktop and other platforms.", + style = FluentTheme.typography.body, + color = FluentTheme.colors.text.text.secondary ) - }, - content = { - Button( - onClick = { isFlyoutVisible = !isFlyoutVisible } - ) { - Text("Open MenuFlyout") - } } - ) + Icon( + Icons.Default.Open, + contentDescription = "Open Link", + Modifier.padding(end = 16.dp), + tint = FluentTheme.colors.text.text.secondary + ) + } } - Dialog( - title = "This is an example dialog", - visible = displayDialog, - cancelButtonText = "Cancel", - confirmButtonText = "Confirm", - onCancel = { - displayDialog = false + Card( + onClick = { + uriHandler.openUri(ProjectUrl.ROOT) }, - onConfirm = { - displayDialog = false - }, - content = { - Text( - "This is body text. Windows 11 marks a visual evolution of the operating system. We have evolved our design language alongside with Fluent to create a design which is human, universal and truly feels like Windows. \n" + - "\n" + - "The design principles below have guided us throughout the journey of making Windows the best-in-class implementation of Fluent.\n", - color = LocalContentColor.current + modifier = Modifier.fillMaxWidth() + ) { + Row( + Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(Res.drawable.github_logo), + contentDescription = null, + modifier = Modifier.size(64.dp).padding(12.dp), + tint = FluentTheme.colors.text.text.primary + ) + Column( + modifier = Modifier.weight(1f).padding(start = 16.dp) + ) { + Text( + text = "compose-fluent-ui", + style = FluentTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "View our source code on GitHub.", + style = FluentTheme.typography.body, + color = FluentTheme.colors.text.text.secondary + ) + } + Icon( + Icons.Default.Open, + contentDescription = "Open Link", + Modifier.padding(end = 16.dp), + tint = FluentTheme.colors.text.text.secondary ) } - ) - } -} - - -@Composable -private fun Controller( - scale: Float, - onScaleChange: (Float) -> Unit, - darkMode: Boolean, - onDarkModeChange: (Boolean) -> Unit -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text("Scale: %.2f".format(scale)) - val density = LocalDensity.current - Button(onClick = { onScaleChange(density.density) }) { Text("Reset") } - Switcher(darkMode, text = "Dark Mode", onCheckStateChange = { onDarkModeChange(it) }) - } - Slider( - modifier = Modifier.width(200.dp), - value = scale, - onValueChange = { onScaleChange(it) }, - valueRange = 1f..10f - ) -} - -@Composable -private fun Content() { - - var sliderValue by remember { mutableStateOf(0.5f) } - Slider( - modifier = Modifier.width(200.dp), - value = sliderValue, - onValueChange = { sliderValue = it }, - ) - Buttons() - - Controls() - - Row { - Layer( - modifier = Modifier.size(32.dp), - shape = RoundedCornerShape(4.dp), - cornerRadius = 4.dp, - color = FluentTheme.colors.fillAccent.default, - border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default), - content = {}, - outsideBorder = false - ) - Layer( - modifier = Modifier.size(32.dp), - shape = RoundedCornerShape(4.dp), - cornerRadius = 4.dp, - color = FluentTheme.colors.fillAccent.default, - border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default), - content = {}, - outsideBorder = true - ) - } - - var value by remember { mutableStateOf(TextFieldValue("Hello Fluent!")) } - TextField(value, onValueChange = { value = it }) - TextField( - value = value, onValueChange = { value = it }, enabled = false, - header = { Text("With Header") } - ) - - // ProgressRings - Row( - horizontalArrangement = Arrangement.spacedBy(32.dp), - verticalAlignment = Alignment.CenterVertically - ) { - ProgressRing(size = ProgressRingSize.Medium) - ProgressRing(progress = sliderValue) - AccentButton(onClick = {}) { - ProgressRing(size = ProgressRingSize.Small, color = LocalContentColor.current) - Text("Small") - } - } - - ProgressBar(sliderValue) - ProgressBar() - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - for (imageVector in icons) { - Icon( - modifier = Modifier.size(18.dp), - imageVector = imageVector, contentDescription = null - ) } } -} - -@Composable -private fun Controls() { - var checked by remember { mutableStateOf(false) } - Switcher(checked, text = null, onCheckStateChange = { checked = it }) - - var checked2 by remember { mutableStateOf(true) } - Switcher(checked2, text = "With Label", onCheckStateChange = { checked2 = it }) - - var checked3 by remember { mutableStateOf(true) } - Switcher( - checked3, - text = "Before Label", - textBefore = true, - onCheckStateChange = { checked3 = it } - ) - - var checked4 by remember { mutableStateOf(false) } - CheckBox(checked4) { checked4 = it } - - var checked5 by remember { mutableStateOf(true) } - CheckBox(checked5, label = "With Label") { checked5 = it } - - var selectedRadio by remember { mutableStateOf(0) } - RadioButton(selectedRadio == 0, onClick = { selectedRadio = 0 }) - RadioButton(selectedRadio == 1, onClick = { selectedRadio = 1 }, label = "With Label") -} - -@Composable -private fun Buttons() { - var text by remember { mutableStateOf("Hello World") } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - val onClick = { text = "Hello, Fluent Design!" } - Button(onClick) { Text(text) } - - AccentButton(onClick) { - Icon(Icons.Default.Checkmark, contentDescription = null) - Text(text) - } - - SubtleButton(onClick) { - Text("Text Button") - } - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - AccentButton({}, iconOnly = true) { - Icon(Icons.Default.Navigation, contentDescription = null) - } - Button({}, iconOnly = true) { - Icon(Icons.Default.Navigation, contentDescription = null) - } - SubtleButton({}, iconOnly = true) { - Icon(Icons.Default.Navigation, contentDescription = null) - } - } -} - -private val icons = arrayOf( - Icons.Default.Add, - Icons.Default.Delete, - Icons.Default.Dismiss, - Icons.Default.ArrowLeft, - Icons.Default.Navigation, - Icons.Default.List -) \ No newline at end of file +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/HomeScreen.kt.bak b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/HomeScreen.kt.bak new file mode 100644 index 00000000..44576c48 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/HomeScreen.kt.bak @@ -0,0 +1,410 @@ +package com.konyaco.fluent.gallery.screen + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CutCornerShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.LocalContentColor +import com.konyaco.fluent.background.BackgroundSizing +import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.component.* +import com.konyaco.fluent.component.rememberScrollbarAdapter +import com.konyaco.fluent.gallery.LocalStore +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.regular.* +import com.konyaco.fluent.surface.Card + + +@Component(icon = "Home") +@Composable +fun HomeScreen() { + var displayDialog by remember { mutableStateOf(false) } + val density = LocalDensity.current + var scale by remember(density) { mutableStateOf(density.density) } + val store = LocalStore.current + + Column(Modifier.verticalScroll(rememberScrollState()).padding(16.dp), Arrangement.spacedBy(8.dp)) { + Controller(scale, { scale = it }, store.darkMode, { store.darkMode = it }) + + CompositionLocalProvider(LocalDensity provides Density(scale)) { + Content() + } + + AccentButton(onClick = { + displayDialog = true + }) { Text("Display Dialog") } + + Box { + var expanded by remember { mutableStateOf(false) } + + Button(onClick = { + expanded = true + }) { + Text("Show DropdownMenu") + } + + fun close() { + expanded = false + } + + DropdownMenu(expanded, ::close) { + DropdownMenuItem(::close) { Text("Option 1") } + DropdownMenuItem(::close) { Text("Option 2") } + DropdownMenuItem(::close) { Text("Option 3") } + } + } + var currentPlacement by remember { + mutableStateOf(FlyoutPlacement.Auto) + } + Row { + + FlyoutContainer( + flyout = { + Text("this is a flyout") + }, + placement = currentPlacement, + content = { + Button( + onClick = { isFlyoutVisible = true } + ) { + Text("Open Flyout") + } + } + ) + Spacer(Modifier.width(8.dp)) + Box { + var isFlyoutPlacementDropdownMenuOpened by remember { + mutableStateOf(false) + } + Button(onClick = { + isFlyoutPlacementDropdownMenuOpened = true + }) { + Text("Flyout placement") + } + val item = @Composable { placement: FlyoutPlacement -> + DropdownMenuItem({ + currentPlacement = placement + isFlyoutPlacementDropdownMenuOpened = false + }) { + Icon( + Icons.Default.Checkmark, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + .alpha(if (placement == currentPlacement) 1f else 0f) + ) + Text(text = placement.toString()) + } + } + DropdownMenu( + isFlyoutPlacementDropdownMenuOpened, + { isFlyoutPlacementDropdownMenuOpened = false }) { + FlyoutPlacement.entries.forEach { item(it) } + } + } + } + + MenuFlyoutContainer( + placement = currentPlacement, + flyout = { + MenuFlyoutItem( + onClick = { + + }, + icon = { + Icon(Icons.Default.Delete, contentDescription = null) + }, + text = { + Text("Delete") + } + ) + MenuFlyoutSeparator() + MenuFlyoutItem( + onClick = { + + }, + icon = { + Icon(Icons.Default.Add, contentDescription = null) + }, + text = { + Text("Add") + } + ) + MenuFlyoutSeparator() + MenuFlyoutItem( + onClick = {}, + icon = {}, + paddingIcon = true, + text = { Text("test") } + ) + MenuFlyoutItem( + items = { + MenuFlyoutItem( + onClick = { + + }, + icon = { + Icon(Icons.Default.Add, contentDescription = null) + }, + text = { + Text("Add") + } + ) + }, + icon = { + Icon(Icons.Default.ClipboardMore, contentDescription = null) + }, + text = { + Text("More") + } + ) + }, + content = { + Button( + onClick = { isFlyoutVisible = !isFlyoutVisible } + ) { + Text("Open MenuFlyout") + } + } + ) + } + + Dialog( + title = "This is an example dialog", + visible = displayDialog, + cancelButtonText = "Cancel", + confirmButtonText = "Confirm", + onCancel = { + displayDialog = false + }, + onConfirm = { + displayDialog = false + }, + content = { + Text( + "This is body text. Windows 11 marks a visual evolution of the operating system. We have evolved our design language alongside with Fluent to create a design which is human, universal and truly feels like Windows. \n" + + "\n" + + "The design principles below have guided us throughout the journey of making Windows the best-in-class implementation of Fluent.\n", + color = LocalContentColor.current + ) + } + ) +} + + +@Composable +private fun Controller( + scale: Float, + onScaleChange: (Float) -> Unit, + darkMode: Boolean, + onDarkModeChange: (Boolean) -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Scale: %.2f".format(scale)) + val density = LocalDensity.current + Button(onClick = { onScaleChange(density.density) }) { Text("Reset") } + Switcher(darkMode, text = "Dark Mode", onCheckStateChange = { onDarkModeChange(it) }) + } + Slider( + modifier = Modifier.width(200.dp), + value = scale, + onValueChange = { onScaleChange(it) }, + valueRange = 1f..10f + ) +} + +@Composable +private fun Content() { + + var sliderValue by remember { mutableStateOf(0.5f) } + Slider( + modifier = Modifier.width(200.dp), + value = sliderValue, + onValueChange = { sliderValue = it }, + ) + Buttons() + + Controls() + + val layerScrollState = rememberScrollState() + ScrollbarContainer( + adapter = rememberScrollbarAdapter(layerScrollState), + isVertical = false + ) { + Row(modifier = Modifier.padding(bottom = 8.dp).horizontalScroll(layerScrollState)) { + Box { + Box(Modifier.size(32.dp).background(FluentTheme.colors.fillAccent.default)) + } + + Layer( + shape = RoundedCornerShape(4.dp), + color = FluentTheme.colors.fillAccent.default, + border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default), + content = { + Box(Modifier.size(32.dp)) + }, + backgroundSizing = BackgroundSizing.InnerBorderEdge + ) + Layer( + shape = RoundedCornerShape(4.dp), + color = FluentTheme.colors.fillAccent.default, + border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default), + content = { + Box(Modifier.size(32.dp)) + }, + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) + + Layer( + shape = CutCornerShape(4.dp), + color = FluentTheme.colors.fillAccent.default, + border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default.copy(0.5f)), + content = { + Box(Modifier.size(32.dp)) + }, + backgroundSizing = BackgroundSizing.InnerBorderEdge + ) + Layer( + shape = CutCornerShape(4.dp), + color = FluentTheme.colors.fillAccent.default, + border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default.copy(0.5f)), + content = { + Box(Modifier.size(32.dp)) + }, + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) + + Layer( + shape = CircleShape, + color = FluentTheme.colors.fillAccent.default, + border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default), + content = { + Box(Modifier.size(32.dp)) + }, + backgroundSizing = BackgroundSizing.InnerBorderEdge + ) + Layer( + shape = CircleShape, + color = FluentTheme.colors.fillAccent.default, + border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default), + content = { + Box(Modifier.size(32.dp)) + }, + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) + + Card(Modifier) { + Box(Modifier.size(32.dp)) + } + } + } + var value by remember { mutableStateOf(TextFieldValue("Hello Fluent!")) } + TextField(value, onValueChange = { value = it }) + TextField( + value = value, onValueChange = { value = it }, enabled = false, + header = { Text("With Header") } + ) + + // ProgressRings + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ProgressRing(size = ProgressRingSize.Medium) + ProgressRing(progress = sliderValue) + AccentButton(onClick = {}) { + ProgressRing(size = ProgressRingSize.Small, color = LocalContentColor.current) + Text("Small") + } + } + + ProgressBar(sliderValue) + ProgressBar() + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (imageVector in icons) { + Icon( + modifier = Modifier.size(18.dp), + imageVector = imageVector, contentDescription = null + ) + } + } +} + +@Composable +private fun Controls() { + var checked by remember { mutableStateOf(false) } + Switcher(checked, text = null, onCheckStateChange = { checked = it }) + + var checked2 by remember { mutableStateOf(true) } + Switcher(checked2, text = "With Label", onCheckStateChange = { checked2 = it }) + + var checked3 by remember { mutableStateOf(true) } + Switcher( + checked3, + text = "Before Label", + textBefore = true, + onCheckStateChange = { checked3 = it } + ) + + var checked4 by remember { mutableStateOf(false) } + CheckBox(checked4) { checked4 = it } + + var checked5 by remember { mutableStateOf(true) } + CheckBox(checked5, label = "With Label") { checked5 = it } + + var selectedRadio by remember { mutableStateOf(0) } + RadioButton(selectedRadio == 0, onClick = { selectedRadio = 0 }) + RadioButton(selectedRadio == 1, onClick = { selectedRadio = 1 }, label = "With Label") +} + +@Composable +private fun Buttons() { + var text by remember { mutableStateOf("Hello World") } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val onClick = { text = "Hello, Fluent Design!" } + Button(onClick) { Text(text) } + + AccentButton(onClick) { + Icon(Icons.Default.Checkmark, contentDescription = null) + Text(text) + } + + SubtleButton(onClick) { + Text("Text Button") + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AccentButton({}, iconOnly = true) { + Icon(Icons.Default.Navigation, contentDescription = null) + } + Button({}, iconOnly = true) { + Icon(Icons.Default.Navigation, contentDescription = null) + } + SubtleButton({}, iconOnly = true) { + Icon(Icons.Default.Navigation, contentDescription = null) + } + } +} + +private val icons = arrayOf( + Icons.Default.Add, + Icons.Default.Delete, + Icons.Default.Dismiss, + Icons.Default.ArrowLeft, + Icons.Default.Navigation, + Icons.Default.List +) \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ButtonScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ButtonScreen.kt new file mode 100644 index 00000000..df4cf809 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ButtonScreen.kt @@ -0,0 +1,115 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.component.AccentButton +import com.konyaco.fluent.component.Button +import com.konyaco.fluent.component.CheckBox +import com.konyaco.fluent.component.Icon +import com.konyaco.fluent.component.SubtleButton +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.regular.Home +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component( + index = 0, + description = "A control that responds to user input and raises a Click event." +) +@Composable +fun ButtonScreen() { + GalleryPage( + title = "Button", + description = "The Button control provides a Click event to respond to user input from a touch, mouse, keyboard, stylus, or other input device. You can put different kinds of content in a button, such as text or an image, or you can restyle a button to give it a new look.", + componentPath = FluentSourceFile.Button, + galleryPath = ComponentPagePath.ButtonScreen + ) { + val clickTextContent = remember { mutableStateOf("") } + val buttonEnabled = remember { mutableStateOf(true) } + Section( + title = "A simple Button with text content.", + content = { + ButtonSample(enabled = buttonEnabled.value) { clickTextContent.value = "You clicked: Button 1" } + }, + output = { + if (clickTextContent.value.isNotBlank()) { + Text(clickTextContent.value) + } + }, + options = { + CheckBox( + checked = !buttonEnabled.value, + onCheckStateChange = { buttonEnabled.value = !it }, + label = "Disable button" + ) + }, + sourceCode = sourceCodeOfButtonSample + ) + val clickTextContent2 = remember { mutableStateOf("") } + Section( + title = "Button with graphical content.", + content = { GraphicalButtonSample { clickTextContent2.value = "You clicked: Button 2" } }, + output = { + if (clickTextContent2.value.isNotBlank()) { + Text(clickTextContent2.value) + } + }, + sourceCode = sourceCodeOfGraphicalButtonSample + ) + // TODO: Wrapping Buttons with large content. + Section( + title = "Accent style applied to Button.", + content = { AccentButtonSample() }, + sourceCode = sourceCodeOfAccentButtonSample + ) + Section( + title = "Subtle button.", + content = { SubtleButtonSample() }, + sourceCode = sourceCodeOfSubtleButtonSample + ) + } +} + +@Sample +@Composable +private fun ButtonSample(enabled: Boolean = true, onClick: () -> Unit) { + Button(disabled = !enabled, onClick = onClick) { + Text("Standard Compose Button") + } +} + +@Sample +@Composable +private fun GraphicalButtonSample(onClick: () -> Unit) { + Button(modifier = Modifier.size(48.dp), onClick = onClick, iconOnly = true) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Default.Home, + contentDescription = null + ) + } +} + +@Sample +@Composable +private fun AccentButtonSample() { + AccentButton(onClick = {}) { + Text("Accent Compose Button") + } +} + +@Sample +@Composable +private fun SubtleButtonSample() { + SubtleButton(onClick = {}) { + Text("Subtle Compose Button") + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/CheckBoxScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/CheckBoxScreen.kt new file mode 100644 index 00000000..0bcb4ca0 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/CheckBoxScreen.kt @@ -0,0 +1,62 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.konyaco.fluent.component.CheckBox +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.gallery.component.TodoComponent +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component(index = 7, description = "A control that a user can select or clear.") +@Composable +fun CheckBoxScreen() { + GalleryPage( + title = "CheckBox", + description = "CheckBox controls let the user select a combination of binary options. In contrast, RadioButton controls allow the user to select from mutually exclusive options. The indeterminate state is used to indicate that an option is set for some, but not all, child options. Don't allow users to set an indeterminate state directly to indicate a third option.", + componentPath = FluentSourceFile.CheckBox, + galleryPath = ComponentPagePath.CheckBoxScreen + ) { + var twoStateChecked by remember { mutableStateOf(false) } + val twoStateOutput = remember { mutableStateOf("") } + Section( + title = "A 2-state CheckBox.", + sourceCode = sourceCodeOfTwoStateCheckBoxSample, + content = { + TwoStateCheckBoxSample( + checked = twoStateChecked, + onCheckedChanged = { + twoStateChecked = it + twoStateOutput.value = if (it) { + "You checked the box." + } else { + "You unchecked the box." + } + } + ) + }, + output = { + Text(twoStateOutput.value) + } + ) + Section("A 3-state CheckBox.", "") { + TodoComponent() + } + Section("Using a 3-state CheckBox", "") { + TodoComponent() + } + } +} + +@Sample +@Composable +private fun TwoStateCheckBoxSample(checked: Boolean, onCheckedChanged: (Boolean) -> Unit) { + + CheckBox(checked, "Two-state CheckBox", onCheckStateChange = onCheckedChanged) +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ColorPickerScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ColorPickerScreen.kt new file mode 100644 index 00000000..1a0878f8 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ColorPickerScreen.kt @@ -0,0 +1,89 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.component.CheckBox +import com.konyaco.fluent.component.ColorPicker +import com.konyaco.fluent.component.ColorSpectrum +import com.konyaco.fluent.component.RadioButton +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component(index = 8, description = "A control that displays a selectable color spectrum.") +@Composable +fun ColorPickerScreen() { + GalleryPage( + title = "ColorPicker", + description = "A selectable color spectrum.", + componentPath = FluentSourceFile.ColorPicker, + galleryPath = ComponentPagePath.ColorPickerScreen + ) { + val colorSpectrum = remember { mutableStateOf(ColorSpectrum.Default) } + val alphaEnabled = remember { mutableStateOf(false) } + val moreButtonVisible = remember { mutableStateOf(false) } + Section( + title = "ColorPicker Properties", + sourceCode = sourceCodeOfColorPickerSample, + content = { + ColorPickerSample( + colorSpectrum = colorSpectrum.value, + alphaEnabled = alphaEnabled.value, + moreButtonVisible = moreButtonVisible.value + ) + }, + options = { + CheckBox( + checked = moreButtonVisible.value, + onCheckStateChange = { moreButtonVisible.value = it }, + label = "More button visible" + ) + CheckBox( + checked = alphaEnabled.value, + onCheckStateChange = { alphaEnabled.value = it }, + label = "Alpha enabled" + ) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(text = "ColorSpectrum shape", modifier = Modifier.padding(bottom = 4.dp)) + RadioButton( + selected = colorSpectrum.value == ColorSpectrum.Square, + onClick = { colorSpectrum.value = ColorSpectrum.Square }, + label = "Square" + ) + RadioButton( + selected = colorSpectrum.value == ColorSpectrum.Round, + onClick = { colorSpectrum.value = ColorSpectrum.Round }, + label = "Round" + ) + } + } + ) + } +} + +@Sample +@Composable +private fun ColorPickerSample( + colorSpectrum: ColorSpectrum, + alphaEnabled: Boolean = false, + moreButtonVisible: Boolean = false +) { + val (color, setColor) = remember { mutableStateOf(Color.White) } + ColorPicker( + colorSpectrum = colorSpectrum, + color = color, + onSelectedColorChanged = setColor, + alphaEnabled = alphaEnabled, + moreButtonVisible = moreButtonVisible + ) +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ComboBoxScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ComboBoxScreen.kt new file mode 100644 index 00000000..de659c5e --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ComboBoxScreen.kt @@ -0,0 +1,60 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.konyaco.fluent.component.ComboBox +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.gallery.component.TodoComponent + +@Component(index = 9, description = "A drop-down list of items a user can select from.") +@Composable +fun ComboBoxScreen() { + GalleryPage( + title = "ComboBox", + description = "Use a ComboBox when you need to conserve on-screen space and when users select only one option at a time. A ComboBox shows only the currently selected item.", + galleryPath = ComponentPagePath.ComboBoxScreen + ) { + Section( + title = "A ComboBox with its Items source set", + sourceCode = sourceCodeOfItemsSourceComboBoxSample + ) { + ItemsSourceComboBoxSample() + } + + Section( + title = "A ComboBox with items defined inline and its width set", + sourceCode = "" + ) { + TodoComponent() + } + + Section( + title = "An editable ComboBox", + sourceCode = "" + ) { + TodoComponent() + } + } +} + +private val itemsList = listOf("Red", "Green", "Yellow", "Blue", "Pink") + +@Sample +@Composable +private fun ItemsSourceComboBoxSample() { + var selected by remember { mutableStateOf(null) } + + ComboBox( + header = "Color", + placeholder = "Pick a color", + selected = selected, + items = itemsList, + onSelectionChange = { i, s -> selected = i } + ) +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/DropDownButtonScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/DropDownButtonScreen.kt new file mode 100644 index 00000000..0ed173d2 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/DropDownButtonScreen.kt @@ -0,0 +1,82 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.component.* +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.filled.Send +import com.konyaco.fluent.icons.regular.Mail +import com.konyaco.fluent.icons.regular.MailArrowDoubleBack +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component(index = 1, description = "A button that displays a flyout of choices when clicked.") +@Composable +fun DropDownButtonScreen() { + GalleryPage( + title = "DropDownButton", + description = "A control that drops down a flyout of choices from which one can be chosen.", + componentPath = FluentSourceFile.Button, + galleryPath = ComponentPagePath.DropDownButtonScreen + ) { + Section( + title = "Simple DropDownButton", + sourceCode = sourceCodeOfBasicDropDownButton, + content = { BasicDropDownButton() } + ) + Section( + title = "DropDownButton with Icons", + sourceCode = sourceCodeOfIconDropDownButton, + content = { IconDropDownButton() } + ) + } +} + +@Sample +@Composable +private fun BasicDropDownButton() { + MenuFlyoutContainer( + flyout = { + MenuFlyoutItem(text = { Text("Send") }, onClick = { isFlyoutVisible = false }) + MenuFlyoutItem(text = { Text("Reply") }, onClick = { isFlyoutVisible = false }) + MenuFlyoutItem(text = { Text("Reply All") }, onClick = { isFlyoutVisible = false }) + }, + content = { DropDownButton(onClick = { isFlyoutVisible = !isFlyoutVisible }, content = { Text("Email") }) }, + adaptivePlacement = true, + placement = FlyoutPlacement.BottomAlignedStart + ) +} + +@Sample +@Composable +private fun IconDropDownButton() { + MenuFlyoutContainer( + flyout = { + MenuFlyoutItem( + text = { Text("Send") }, + onClick = { isFlyoutVisible = false }, + icon = { Icon(Icons.Filled.Send, contentDescription = "Send", modifier = Modifier.size(20.dp)) }) + MenuFlyoutItem( + text = { Text("Reply") }, + onClick = { isFlyoutVisible = false }, + icon = { Icon(Icons.Default.MailArrowDoubleBack, contentDescription = "Reply", modifier = Modifier.size(20.dp)) }) + MenuFlyoutItem( + text = { Text("Reply All") }, + onClick = { isFlyoutVisible = false }, + icon = { Icon(Icons.Default.MailArrowDoubleBack, contentDescription = "Reply All", modifier = Modifier.size(20.dp)) }) + }, + content = { + DropDownButton( + onClick = { isFlyoutVisible = !isFlyoutVisible }, + content = { Icon(Icons.Default.Mail, contentDescription = null, modifier = Modifier.size(24.dp)) } + ) + }, + adaptivePlacement = true, + placement = FlyoutPlacement.BottomAlignedStart + ) +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/HyperlinkButtonScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/HyperlinkButtonScreen.kt new file mode 100644 index 00000000..e30ea615 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/HyperlinkButtonScreen.kt @@ -0,0 +1,63 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.konyaco.fluent.component.CheckBox +import com.konyaco.fluent.component.HyperlinkButton +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.* +import com.konyaco.fluent.gallery.component.Basic_input_ToggleButtonScreenComponent +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component(index = 2, description = "A button that appears as hyperlink text, and can navigate to a URl or handle a Click event.") +@Composable +fun HyperlinkButtonScreen(navigator: ComponentNavigator) { + GalleryPage( + title = "HyperlinkButton", + description = "A HyperlinkButton appears as a text hyperlink. When a user clicks it, it opens the page you specify in the NavigateUri property in the default browser. Or you can handle its Click event, typically to navigate within your app.", + componentPath = FluentSourceFile.Button, + galleryPath = ComponentPagePath.HyperlinkButtonScreen + ) { + val enabled = remember { mutableStateOf(true) } + Section( + title = "A hyperlink button that navigates to a URl.", + sourceCode = sourceCodeOfNavigateUriHyperlinkButtonSample, + content = { NavigateUriHyperlinkButtonSample(enabled.value) }, + options = { + CheckBox( + checked = !enabled.value, + onCheckStateChange = { enabled.value = !it }, + label = "Disable hyperlink button" + ) + } + ) + Section( + title = "A hyperlink button that handles a Click event.", + sourceCode = sourceCodeOfClickEventHyperlinkButtonSample, + content = { + ClickEventHyperlinkButtonSample { + navigator.navigate(Basic_input_ToggleButtonScreenComponent) + } + } + ) + } +} + +@Sample +@Composable +private fun NavigateUriHyperlinkButtonSample(enabled: Boolean) { + HyperlinkButton(navigateUri = "https://www.microsoft.com", disabled = !enabled) { + Text("Microsoft home page") + } +} + +@Sample +@Composable +private fun ClickEventHyperlinkButtonSample(onClick: () -> Unit) { + HyperlinkButton(onClick = onClick) { + Text("Go to ToggleButton") + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/RadioButtonScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/RadioButtonScreen.kt new file mode 100644 index 00000000..fc1b2b76 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/RadioButtonScreen.kt @@ -0,0 +1,64 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.component.RadioButton +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component( + index = 10, + description = "A control that allows a user to select a single option from a group of options." +) +@Composable +fun RadioButtonScreen() { + GalleryPage( + title = "RadioButton", + description = "Use RadioButtons to let a user choose between mutually exclusive, related options. Generally contained within a RadioButtons group control.", + componentPath = FluentSourceFile.RadioButton, + galleryPath = ComponentPagePath.RadioButtonScreen + ) { + val output = remember { mutableStateOf("Select an option.") } + Section( + title = "A group of RadioButton controls.", + sourceCode = sourceCodeOfRadioButtonSample, + content = { + RadioButtonSample { + output.value = "You selected Option $it" + } + }, + output = { + Text(output.value) + } + ) + } +} + +@Sample +@Composable +fun RadioButtonSample(onOptionSelected: (index: Int) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Options:") + var selected by remember { mutableStateOf(0) } + for (index in 1..3) { + RadioButton( + selected = selected == index, + label = "Option $index", + onClick = { + selected = index + onOptionSelected(index) + } + ) + } + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/RatingControlScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/RatingControlScreen.kt new file mode 100644 index 00000000..619bff4b --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/RatingControlScreen.kt @@ -0,0 +1,104 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.component.CheckBox +import com.konyaco.fluent.component.RatingControl +import com.konyaco.fluent.component.Slider +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile +import kotlin.math.roundToInt + +@Component(index = 11, description = "Rate something 1 to 5 stars.") +@Composable +fun RatingControlScreen() { + GalleryPage( + title = "RatingControl", + description = "Rate something 1 to 5 stars.", + componentPath = FluentSourceFile.RatingControl, + galleryPath = ComponentPagePath.RatingControlScreen + ) { + val (value, setValue) = remember { mutableFloatStateOf(0.5f) } + val isClearEnabled = remember { mutableStateOf(false) } + val isReadOnly = remember { mutableStateOf(false) } + Section( + title = "A simple RatingControl", + sourceCode = sourceCodeOfBasicRatingControlSample, + content = { + BasicRatingControlSample( + value = value, + onValueChanged = setValue, + isClearEnabled = isClearEnabled.value, + isReadOnly = isReadOnly.value + ) + }, + output = { + Text(value.toString()) + }, + options = { + CheckBox( + checked = isClearEnabled.value, + onCheckStateChange = { isClearEnabled.value = it }, + label = "Is Clear Enabled" + ) + Text("Swipe left to clear your rating.", modifier = Modifier.padding(bottom = 8.dp)) + CheckBox( + checked = isReadOnly.value, + onCheckStateChange = { isReadOnly.value = it }, + label = "Is Read Only" + ) + } + ) + val (placeholderValue, setPlaceholderValue) = remember { mutableFloatStateOf(2f) } + Section( + title = "PlaceholderValue of RatingControl", + sourceCode = sourceCodeOfPlaceholderRatingControlSample, + content = { PlaceholderRatingControlSample(placeholderValue) }, + options = { + Text("PlaceholderValue") + Slider( + value = placeholderValue, + onValueChange = { + setPlaceholderValue((it / 0.5f).roundToInt() * 0.5f) + }, + valueRange = 0f..5f, + modifier = Modifier.size(200.dp, 32.dp), + ) + } + ) + } +} + +@Sample +@Composable +private fun BasicRatingControlSample( + value: Float, + onValueChanged: (Float) -> Unit, + isClearEnabled: Boolean = false, + isReadOnly: Boolean = false, +) { + RatingControl( + value = value, + onValueChanged = onValueChanged, + caption = { Text("Your rating") }, + isClearEnabled = isClearEnabled, + isReadOnly = isReadOnly + ) +} + +@Sample +@Composable +private fun PlaceholderRatingControlSample(placeHolderValue: Float = 2f) { + val (value, setValue) = remember { mutableFloatStateOf(0.0f) } + RatingControl(value = value, onValueChanged = setValue, isClearEnabled = true, placeholderValue = placeHolderValue) +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/RepeatButtonScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/RepeatButtonScreen.kt new file mode 100644 index 00000000..b307e9e1 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/RepeatButtonScreen.kt @@ -0,0 +1,56 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.component.CheckBox +import com.konyaco.fluent.component.RepeatButton +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component(index = 3, description = "A button that raises its Click event repeatedly from the time it's pressed until it's released.") +@Composable +fun RepeatButtonScreen() { + GalleryPage( + title = "RepeatButton", + description = "The RepeatButton control is like a standard Button, except that the Click event occurs continuously while the user presses the RepeatButton.", + componentPath = FluentSourceFile.Button, + galleryPath = ComponentPagePath.RepeatButtonScreen + ) { + val enabled = remember { mutableStateOf(true) } + Section( + title = "A simple RepeatButton with text content.", + sourceCode = sourceCodeOfBasicRepeatButtonSample, + content = { BasicRepeatButtonSample(enabled.value) }, + options = { + CheckBox( + checked = !enabled.value, + onCheckStateChange = { enabled.value = !it }, + label = "Disable repeat button" + ) + } + ) + } +} + +@Sample +@Composable +private fun BasicRepeatButtonSample(enabled: Boolean) { + val clickCount = remember { mutableIntStateOf(0) } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + RepeatButton(onClick = { clickCount.value += 1 }, disabled = !enabled) { + Text("Click and hold") + } + Text("Number of clicks: ${clickCount.value}") + } + +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/SliderScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/SliderScreen.kt new file mode 100644 index 00000000..9e251d35 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/SliderScreen.kt @@ -0,0 +1,63 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +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.unit.dp +import com.konyaco.fluent.component.Slider +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.gallery.component.TodoComponent +import com.konyaco.fluent.source.generated.FluentSourceFile +import kotlin.math.roundToInt + +@Component( + index = 12, + description = "A control that lets the user select from a range of values by moving a Thumb control along a track." +) +@Composable +fun SliderScreen() { + GalleryPage( + title = "Slider", + description = "Use a Slider when you want your users to be able to set defined, contiguous values (such as volume or brightness) or a range of discrete values (such as screen resolution settings).", + componentPath = FluentSourceFile.Slider, + galleryPath = ComponentPagePath.SliderScreen + ) { + val (value, onValueChanged) = remember { mutableStateOf(0f) } + Section( + title = "A simple Slider.", + sourceCode = sourceCodeOfSliderSample, + content = { SliderSample(value, onValueChanged) }, + output = { + Text((value * 100).roundToInt().toString()) + } + ) + Section("A Slider with range and steps specified.", "") { + TodoComponent() + } + Section("A Slider with tick marks.", "") { + TodoComponent() + } + Section("A vertical slider with range and tick marks specified.", "") { + TodoComponent() + } + } +} + +@Sample +@Composable +private fun SliderSample(value: Float, onValueChanged: (Float) -> Unit) { + Slider( + modifier = Modifier.width(200.dp).height(32.dp), + value = value, + onValueChange = onValueChanged + ) +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/SplitButtonScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/SplitButtonScreen.kt new file mode 100644 index 00000000..ec4f0728 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/SplitButtonScreen.kt @@ -0,0 +1,163 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.component.* +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component( + index = 5, + description = "A two-part button that displays a flyout when its secondary part is clicked." +) +@Composable +fun SplitButtonScreen() { + GalleryPage( + title = "SplitButton", + description = "The SplitButton is a dropdown button, but with an addition execution hit target.", + componentPath = FluentSourceFile.Button, + galleryPath = ComponentPagePath.SplitButtonScreen + ) { + //TODO TextField keep focus + val textFieldValue = remember { + mutableStateOf( + TextFieldValue( + AnnotatedString( + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, " + + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + + " Tempor commodo ullamcorper a lacus.", + spanStyle = SpanStyle(color = colors.first()) + ) + ) + ) + } + Section( + title = "A SplitButton controlling text color in a RichEditBox", + sourceCode = sourceCodeOfBasicSplitButtonSample, + content = { + BasicSplitButtonSample { + textFieldValue.value = textFieldValue.value.copy( + buildAnnotatedString { + append(textFieldValue.value.annotatedString) + val selection = textFieldValue.value.selection + + if (!selection.reversed && !selection.collapsed) { + addStyle( + style = SpanStyle(color = it), + start = selection.start, + end = selection.length + ) + } + } + ) + } + }, + options = { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + TextField( + value = textFieldValue.value, + onValueChange = { + if (isFocused) { + textFieldValue.value = it + } + }, + modifier = Modifier.width(200.dp), + interactionSource = interactionSource + ) + } + ) + Section( + title = "A SplitButton with text", + sourceCode = sourceCodeOfSplitButtonWithTextSample, + content = { SplitButtonWithTextSample() } + ) + } +} + +@Sample +@Composable +private fun BasicSplitButtonSample(onColorSelected: (color: Color) -> Unit) { + var currentColor by remember { mutableStateOf(colors.first()) } + FlyoutContainer( + flyout = { + ColorList { + currentColor = it + onColorSelected(it) + isFlyoutVisible = false + } + }, + adaptivePlacement = true, + placement = FlyoutPlacement.Bottom + ) { + SplitButton( + flyoutClick = { isFlyoutVisible = true }, + onClick = {} + ) { + Box(modifier = Modifier.size(32.dp).background(currentColor)) + } + } +} + +@Sample +@Composable +private fun SplitButtonWithTextSample() { + var currentColor by remember { mutableStateOf(colors.first()) } + FlyoutContainer( + flyout = { + ColorList { + currentColor = it + isFlyoutVisible = false + } + }, + adaptivePlacement = true, + placement = FlyoutPlacement.Bottom + ) { + SplitButton( + flyoutClick = { isFlyoutVisible = true }, + onClick = {} + ) { + Text("Choose color", modifier = Modifier.padding(horizontal = 2.dp)) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ColorList( + onColorSelected: (color: Color) -> Unit +) { + FlowRow( + maxItemsInEachRow = 3, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + colors.forEach { + SubtleButton( + onClick = { + onColorSelected(it) + }, + iconOnly = true + ) { + Box(modifier = Modifier.size(32.dp).background(it, RoundedCornerShape(2.dp))) + } + } + } +} + +private val colors = + listOf(Color.Red, Color.Blue, Color.Cyan, Color.Magenta, Color.Yellow, Color.Green, Color.Gray) \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ToggleButtonScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ToggleButtonScreen.kt new file mode 100644 index 00000000..248995aa --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ToggleButtonScreen.kt @@ -0,0 +1,50 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.konyaco.fluent.component.CheckBox +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.component.ToggleButton +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component(index = 4, description = "A button that can be switched between two states like a CheckBox.") +@Composable +fun ToggleButtonScreen() { + GalleryPage( + title = "ToggleButton", + description = "A ToggleButton looks like a Button, but works like a CheckBox. It typically has two states, checked (on) or unchecked (off), but can be indeterminate if the IsThreeState property is true. You can determine it's state by checking the IsChecked property.", + componentPath = FluentSourceFile.Button, + galleryPath = ComponentPagePath.ToggleButtonScreen + ) { + val enabled = remember { mutableStateOf(true) } + val (checked, setChecked) = remember { mutableStateOf(false) } + Section( + title = "A simple ToggleButton with text content.", + sourceCode = sourceCodeOfBasicToggleButton, + content = { BasicToggleButton(enabled.value, checked, setChecked) }, + options = { + CheckBox( + checked = !enabled.value, + onCheckStateChange = { enabled.value = !it }, + label = "Disable toggle button" + ) + }, + output = { + Text( + text = if (checked) { "On" } else { "Off" } + ) + } + ) + } +} + +@Sample +@Composable +private fun BasicToggleButton(enabled: Boolean, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + ToggleButton(checked, onCheckedChange, disabled = !enabled) { Text("ToggleButton") } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ToggleSplitButtonScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ToggleSplitButtonScreen.kt new file mode 100644 index 00000000..c87244a0 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ToggleSplitButtonScreen.kt @@ -0,0 +1,73 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.component.* +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.regular.List +import com.konyaco.fluent.icons.regular.TextBulletListLtr +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component(index = 6, description = "A version of the SplitButton where the activation target toggles on/off.") +@Composable +fun ToggleSplitButtonScreen() { + GalleryPage( + title = "ToggleSplitButton", + description = "A version of the SplitButton where the activation target toggles on/off.", + componentPath = FluentSourceFile.Button, + galleryPath = ComponentPagePath.ToggleSplitButtonScreen + ) { + Section( + title = "Using ToggleSplitButton to control bulleted list functionality in RichEditBox", + sourceCode = sourceCodeOfBasicToggleSplitButtonSample, + content = { BasicToggleSplitButtonSample() } + ) + } +} + +@Sample +@Composable +private fun BasicToggleSplitButtonSample() { + val (checked, setChecked) = remember { mutableStateOf(false) } + var currentIcon by remember { mutableStateOf(icons.first()) } + FlyoutContainer( + flyout = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 4.dp)) { + icons.forEach { + Button( + onClick = { + currentIcon = it + setChecked(true) + isFlyoutVisible = false + }, + iconOnly = true + ) { + Icon(it, null) + } + } + + } + }, + adaptivePlacement = true, + placement = FlyoutPlacement.Bottom + ) { + ToggleSplitButton( + flyoutClick = { + isFlyoutVisible = true + }, + onClick = { setChecked(!checked) }, + checked = checked + ) { + Icon(currentIcon, null, modifier = Modifier.widthIn(32.dp).wrapContentWidth(Alignment.CenterHorizontally)) + } + } +} + +private val icons = listOf(Icons.Default.List, Icons.Default.TextBulletListLtr) \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ToggleSwitchScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ToggleSwitchScreen.kt new file mode 100644 index 00000000..5edf89b3 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/basicinput/ToggleSwitchScreen.kt @@ -0,0 +1,44 @@ +package com.konyaco.fluent.gallery.screen.basicinput + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.konyaco.fluent.component.Switcher +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.gallery.component.TodoComponent +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component(index = 13, description = "A switch that can be toggled between 2 states.") +@Composable +fun ToggleSwitchScreen() { + GalleryPage( + title = "ToggleSwitch", + description = "Use ToggleSwitch controls to present users with exactly two mutually exclusive options (like on/off), where choosing an option results in an immediate commit. A toggle switch should have a single label.", + componentPath = FluentSourceFile.Switcher, + galleryPath = ComponentPagePath.ToggleSwitchScreen + ) { + Section( + title = "A simple ToggleSwitch.", + sourceCode = sourceCodeOfToggleSwitchSample, + content = { ToggleSwitchSample() } + ) + Section( + title = "A ToggleSwitch with custom header and content.", + sourceCode = "" + ) { + TodoComponent() + } + } +} + +@Sample +@Composable +private fun ToggleSwitchSample() { + var checked by remember { mutableStateOf(false) } + Switcher(checked, { checked = it }, text = if (checked) "On" else "Off") +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/collections/ListItemScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/collections/ListItemScreen.kt new file mode 100644 index 00000000..8f0caf6e --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/collections/ListItemScreen.kt @@ -0,0 +1,162 @@ +package com.konyaco.fluent.gallery.screen.collections + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +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.unit.dp +import com.konyaco.fluent.CompactMode +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.component.ListHeader +import com.konyaco.fluent.component.ListItem +import com.konyaco.fluent.component.ListItemSelectionType +import com.konyaco.fluent.component.RadioButton +import com.konyaco.fluent.component.Switcher +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.LocalStore +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component(index = 0, description = "Basic list item templates for use in a variety of controls") +@Composable +fun ListItemScreen() { + GalleryPage( + title = "ListItem", + description = "Reusable list item templates", + galleryPath = ComponentPagePath.ListItemScreen, + componentPath = FluentSourceFile.ListItem + ) { + Section( + title = "Basic ListItem sample", + sourceCode = sourceCodeOfBasicListItemSample, + content = { BasicListItemSample() } + ) + val type = remember { mutableStateOf(ListItemSelectionType.Standard) } + Section( + title = "Select type ListItem sample", + sourceCode = sourceCodeOfListItemSampleWithSelectionType, + options = { + Text("Selection type", style = FluentTheme.typography.bodyLarge) + ListItemSelectionType.entries.forEach { + RadioButton( + selected = type.value == it, + onClick = { type.value = it }, + label = it.name, + modifier = Modifier.fillMaxWidth() + ) + } + }, + content = { ListItemSampleWithSelectionType(type.value) } + ) + val store = LocalStore.current + val compactMode = remember(store.compactMode) { mutableStateOf(store.compactMode) } + Section( + title = "ListItem with compact mode", + sourceCode = sourceCodeOfListItemWithCompactMode, + content = { ListItemWithCompactMode(compactMode.value) }, + options = { + Switcher( + checked = compactMode.value, + onCheckStateChange = { compactMode.value = it }, + text = "Compact Mode Enabled" + ) + } + ) + Section( + title = "ListItem with header sample", + sourceCode = sourceCodeOfListItemWithHeaderSample, + content = { ListItemWithHeaderSample() } + ) + } +} + +@Sample +@Composable +private fun BasicListItemSample() { + LazyColumn(modifier = Modifier.height(200.dp)) { + items(1000) { + ListItem( + onClick = {}, + text = { Text("Item ${it + 1}") }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Sample +@Composable +private fun ListItemSampleWithSelectionType(selectionType: ListItemSelectionType) { + var selectedIndex by remember { mutableStateOf(-1) } + LazyColumn(modifier = Modifier.height(200.dp)) { + items(1000) { + ListItem( + selected = selectedIndex == it, + onSelectedChanged = { selected -> + selectedIndex = if (!selected) { + -1 + } else { + it + } + }, + text = { Text("Item ${it + 1}") }, + selectionType = selectionType, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Sample +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ListItemWithHeaderSample() { + LazyColumn(modifier = Modifier.height(200.dp)) { + for (i in 1..3) { + stickyHeader { + ListHeader(content = { Text("Group $i") }) + } + items(20) { + ListItem( + onClick = {}, + text = { Text("Item ${it + 1}") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + } +} + +@Sample +@Composable +private fun ListItemWithCompactMode(enabled: Boolean) { + var selectedIndex by remember { mutableStateOf(-1) } + CompactMode(enabled = enabled) { + LazyColumn(modifier = Modifier.height(200.dp)) { + items(1000) { + ListItem( + selected = it == selectedIndex, + onSelectedChanged = { selected -> + selectedIndex = if (!selected) { + -1 + } else { + it + } + }, + text = { Text("Item ${it + 1}") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/datetime/DateTimeScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/datetime/DateTimeScreen.kt new file mode 100644 index 00000000..985bffa5 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/datetime/DateTimeScreen.kt @@ -0,0 +1,46 @@ +@file:OptIn(ExperimentalFluentApi::class) + +package com.konyaco.fluent.gallery.screen.datetime + +import androidx.compose.runtime.Composable +import com.konyaco.fluent.ExperimentalFluentApi +import com.konyaco.fluent.component.CalendarDatePicker +import com.konyaco.fluent.component.CalendarView +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.GalleryPage + +@Component +@Composable +fun DateTimeScreen() { + GalleryPage( + title = "DateTime", + description = "Lets users pick a date value using a calendar.", + ) { + Section( + title = "A CalendarDatePicker", + sourceCode = sourceCodeOfDatePickerSample + ) { + DatePickerSample() + } + + Section( + title = "CalendarView shows a large view for showing and selecting dates.", + sourceCode = sourceCodeOfCalendarViewSample + ) { + CalendarViewSample() + } + } +} + +@Sample +@Composable +private fun DatePickerSample() { + CalendarDatePicker(onChoose = {}) +} + +@Sample +@Composable +private fun CalendarViewSample() { + CalendarView(onChoose = {}) +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/design/IconsScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/design/IconsScreen.kt new file mode 100644 index 00000000..370c1a0a --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/design/IconsScreen.kt @@ -0,0 +1,295 @@ +package com.konyaco.fluent.gallery.screen.design + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.ExperimentalFluentApi +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.FluentThemeConfiguration +import com.konyaco.fluent.background.BackgroundSizing +import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.component.Icon +import com.konyaco.fluent.component.RadioButton +import com.konyaco.fluent.component.ScrollbarContainer +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.component.TextField +import com.konyaco.fluent.component.rememberScrollbarAdapter +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.CopyButton +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.source.generated.FluentSourceFile +import com.konyaco.fluent.source.generated.fluentIconCoreItems +import com.konyaco.fluent.source.generated.fluentIconExtendedItems +import com.konyaco.fluent.surface.Card +import com.konyaco.fluent.surface.CardDefaults +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce + +@OptIn(FlowPreview::class, ExperimentalFluentApi::class, ExperimentalFoundationApi::class) +@Component(index = 1, name = "Icons", icon = "Diversity") +@Composable +fun IconsScreen() { + GalleryPage( + title = "Icons", + description = "With the release of Windows 11, Segoe Fluent Icons is the recommended icon font.", + componentPath = FluentSourceFile.Icon, + galleryPath = ComponentPagePath.IconsScreen + ) { + val icons = remember { mutableStateOf(emptyList>()) } + val iconType = remember { mutableStateOf(Icons.Default) } + val iconCoreSet = remember { mutableStateOf(emptyList>()) } + LaunchedEffect(iconType.value) { + val set = when (iconType.value) { + Icons.Regular -> { + iconCoreSet.value = Icons.Regular.fluentIconCoreItems() + iconCoreSet.value + Icons.Regular.fluentIconExtendedItems() + } + + Icons.Filled -> { + iconCoreSet.value = Icons.Filled.fluentIconCoreItems() + iconCoreSet.value + Icons.Filled.fluentIconExtendedItems() + } + + else -> { + emptyList() + } + } + icons.value = set.sortedBy { it.first } + } + val keywordState = remember { mutableStateOf(TextFieldValue()) } + val selectedItem = remember { mutableStateOf?>(null) } + val filterList = remember { + combine( + snapshotFlow { icons.value }, + snapshotFlow { keywordState.value.text } + .debounce(500) + ) { icons, keyword -> + selectedItem.value = null + icons.filter { it.first.contains(keyword, ignoreCase = true) } + } + }.collectAsState(emptyList()) + Section { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Fluent Icons Library", style = FluentTheme.typography.bodyStrong) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = keywordState.value, + onValueChange = { keywordState.value = it }, + placeholder = { Text("Search icons") }, + modifier = Modifier.defaultMinSize(240.dp) + ) + RadioButton( + selected = iconType.value == Icons.Regular, + label = "Regular", + onClick = { iconType.value = Icons.Regular } + ) + RadioButton( + selected = iconType.value == Icons.Filled, + label = "Filled", + onClick = { iconType.value = Icons.Filled } + ) + } + FluentThemeConfiguration(colors = it) { + Layer( + backgroundSizing = BackgroundSizing.OuterBorderEdge, + color = FluentTheme.colors.background.solid.base + ) { + val listState = rememberLazyGridState() + val adapter = rememberScrollbarAdapter(listState) + Row(modifier = Modifier.height(650.dp)) { + ScrollbarContainer( + adapter = adapter, + modifier = Modifier.weight(1f).fillMaxHeight() + ) { + val defaultColors = CardDefaults.cardColors() + val cardColors = defaultColors.copy( + default = defaultColors.default.copy( + borderBrush = SolidColor(Color.Transparent) + ), + hovered = defaultColors.hovered.copy( + borderBrush = SolidColor(Color.Transparent) + ), + pressed = defaultColors.pressed.copy( + borderBrush = SolidColor(Color.Transparent) + ), + focused = defaultColors.focused.copy( + borderBrush = defaultColors.default.borderBrush + ) + ) + LazyVerticalGrid( + state = listState, + columns = GridCells.Adaptive(96.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(12.dp), + modifier = Modifier.fillMaxSize() + ) { + items( + items = filterList.value, + contentType = { "icon" }, + key = { (name, _) -> name } + ) { item -> + val (name, icon) = item + val interactionSource = + remember { MutableInteractionSource() } + Card( + onClick = { + selectedItem.value = item + }, + cardColors = if (selectedItem.value == item) { + defaultColors + } else { + cardColors + }, + interactionSource = interactionSource, + modifier = Modifier.fillMaxWidth() + .aspectRatio(1f) + ) { + val isHovered by interactionSource.collectIsHoveredAsState() + Box( + modifier = Modifier.fillMaxSize() + ) { + Icon( + imageVector = icon, + contentDescription = name, + modifier = Modifier.padding(bottom = 8.dp) + .size(28.dp).align(Alignment.Center) + ) + Text( + text = name, + style = FluentTheme.typography.caption, + color = FluentTheme.colors.text.text.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(8.dp) + .then( + if (isHovered) { + Modifier.basicMarquee() + } else { + Modifier + } + ) + ) + } + + } + } + } + } + Spacer( + modifier = Modifier.fillMaxHeight() + .padding(vertical = 1.dp) + .width(1.dp) + .background(FluentTheme.colors.stroke.card.default) + ) + Layer( + shape = RectangleShape, + color = FluentTheme.colors.background.card.default, + backgroundSizing = BackgroundSizing.OuterBorderEdge, + border = null, + modifier = Modifier.fillMaxHeight() + .width(400.dp) + ) board@{ + val item = selectedItem.value ?: return@board + val isCore = iconCoreSet.value.contains(item) + val packageName = if (isCore) { + "com.konyaco:fluent-icons-core" + } else { + "com.konyaco:fluent-icons-extended" + } + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(16.dp) + ) { + Text(item.first, style = FluentTheme.typography.subtitle) + Icon( + imageVector = item.second, + contentDescription = item.first, + modifier = Modifier.size(64.dp) + ) + IconIntroSection( + header = "package", + content = packageName + ) + val iconCode = "Icons.${if (iconType.value == Icons.Filled) "Filled" else "Regular"}.${item.first}" + IconIntroSection( + header = "Kotlin", + content = """ + Icon( + imageVector = $iconCode, + contentDescription = "${item.first}" + ) + """.trimIndent() + ) + } + } + } + } + } + } + } + } +} + +@Composable +private fun IconIntroSection( + header: String, + content: String, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = header, style = FluentTheme.typography.body) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = content, + style = FluentTheme.typography.body, color = FluentTheme.colors.text.text.secondary, + modifier = Modifier.weight(1f) + ) + CopyButton(content) + } + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/design/TypographyScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/design/TypographyScreen.kt new file mode 100644 index 00000000..2fa2501f --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/design/TypographyScreen.kt @@ -0,0 +1,204 @@ +@file:OptIn(ExperimentalTextApi::class) + +package com.konyaco.fluent.gallery.screen.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.UrlAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withAnnotation +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.CopyButton +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component(index = 1, icon = "TextFont") +@Composable +fun TypographyScreen() { + val linkTextColor = FluentTheme.colors.text.accent.primary + GalleryPage( + title = AnnotatedString("Typography"), + description = buildAnnotatedString { + append("Type helps provide structure and hierarchy to UI. ") + append("The default font for Windows is ") + withAnnotation(UrlAnnotation("https://learn.microsoft.com/zh-cn/windows/apps/design/downloads/#fonts")) { + append( + AnnotatedString( + "Segoe UI Variable", + spanStyle = SpanStyle( + color = linkTextColor, + textDecoration = TextDecoration.Underline + ) + ) + ) + } + append(". ") + append("Best practice is to use Regular weight for most text, use Semibold for titles. ") + append("The minimum values should be 12px Regular, 14px Semibold.") + }, + componentPath = FluentSourceFile.Typography, + galleryPath = ComponentPagePath.TypographyScreen + ) { + Section( + title = "Type ramp", + sourceCode = sourceCodeOfBasicTypographySample, + content = { + TypographySample() + } + ) + } +} + +@Sample +@Composable +private fun BasicTypographySample() { + Column { + Text("Caption", style = FluentTheme.typography.caption) + Text("Body", style = FluentTheme.typography.body) + Text("Body Strong", style = FluentTheme.typography.bodyStrong) + Text("Subtitle", style = FluentTheme.typography.subtitle) + Text("Title", style = FluentTheme.typography.title) + Text("Title Large", style = FluentTheme.typography.titleLarge) + Text("Display", style = FluentTheme.typography.display) + } +} + +@Composable +private fun TypographySample() { + Column { + HeaderItemRow() + typographyList().forEachIndexed { index, (name, style) -> + ItemRow( + text = { Text(text = name, style = style) }, + secondary = { + Text( + text = when (style.fontWeight) { + FontWeight.Normal -> "Regular" + FontWeight.SemiBold -> "Semibold" + else -> "" + }, + style = FluentTheme.typography.caption + ) + }, + third = { + Text( + text = "${style.fontSize.value.toInt()}/${style.lineHeight.value.toInt()} sp", + style = FluentTheme.typography.caption + ) + }, + fourth = { + val content = when (style) { + FluentTheme.typography.caption -> "FluentTheme.typography.caption" + FluentTheme.typography.body -> "FluentTheme.typography.body" + FluentTheme.typography.bodyStrong -> "FluentTheme.typography.bodyStrong" + FluentTheme.typography.subtitle -> "FluentTheme.typography.subtitle" + FluentTheme.typography.title -> "FluentTheme.typography.title" + FluentTheme.typography.titleLarge -> "FluentTheme.typography.titleLarge" + FluentTheme.typography.display -> "FluentTheme.typography.display" + else -> "" + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = content, + modifier = Modifier.width(240.dp) + ) + CopyButton(content) + } + }, + index = index + 1 + ) + } + } +} + +@Composable +private fun ItemRow( + text: @Composable () -> Unit, + secondary: @Composable () -> Unit, + third: @Composable () -> Unit, + fourth: @Composable () -> Unit, + index: Int, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth().heightIn(68.dp).then( + if (index.mod(2) == 1) { + Modifier.background( + FluentTheme.colors.background.card.default, + shape = RoundedCornerShape(4.dp) + ) + } else { + Modifier + } + ) + ) { + Box(modifier = Modifier.width(272.dp).padding(horizontal = 16.dp, vertical = 16.dp)) { + text() + } + Box(modifier = Modifier.width(136.dp)) { + secondary() + } + Box(modifier = Modifier.width(112.dp)) { + third() + } + fourth() + } +} + +@Composable +private fun HeaderItemRow() { + val headerStyle = + FluentTheme.typography.caption.copy(color = FluentTheme.colors.text.text.secondary) + ItemRow( + text = { + Text("Example", style = headerStyle) + }, + secondary = { + Text("Variable Font", style = headerStyle) + }, + third = { + Text("Size/Line height", style = headerStyle) + }, + fourth = { + Text("Style", style = headerStyle) + }, + index = 0 + ) +} + +@Stable +@Composable +private fun typographyList(): List> { + return listOf( + "Caption" to FluentTheme.typography.caption, + "Body" to FluentTheme.typography.body, + "Body Strong" to FluentTheme.typography.bodyStrong, + "Subtitle" to FluentTheme.typography.subtitle, + "Title" to FluentTheme.typography.title, + "Title Large" to FluentTheme.typography.titleLarge, + "Display" to FluentTheme.typography.display + ) +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/dialogs/ContentDialogScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/dialogs/ContentDialogScreen.kt new file mode 100644 index 00000000..6d6e4b8b --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/dialogs/ContentDialogScreen.kt @@ -0,0 +1,89 @@ +package com.konyaco.fluent.gallery.screen.dialogs + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.konyaco.fluent.component.Button +import com.konyaco.fluent.component.ContentDialog +import com.konyaco.fluent.component.DialogSize +import com.konyaco.fluent.component.LocalContentDialog +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile +import kotlinx.coroutines.launch + +@Component(index = 0, description = "A dialog box that can be customized to contain any content.") +@Composable +fun ContentDialogScreen() { + GalleryPage( + title = "ContentDialog", + description = "Use a ContentDialog to show relevant information or to provide a modal dialog experience that can show any Compose content.", + componentPath = FluentSourceFile.Dialog, + galleryPath = ComponentPagePath.ContentDialogScreen + ) { + Section( + title = "A basic content dialog with content.", + sourceCode = sourceCodeOfBasicContentDialogSample, + content = { BasicContentDialogSample() } + ) + Section( + title = "Use content dialog by LocalContentDialog.", + sourceCode = sourceCodeOfLocalContentDialogSample, + content = { LocalContentDialogSample() } + ) + } + +} + +@Sample +@Composable +private fun BasicContentDialogSample() { + var displayDialog by remember { mutableStateOf(false) } + ContentDialog( + title = "This is an example dialog", + visible = displayDialog, + size = DialogSize.Max, + primaryButtonText = "Confirm", + closeButtonText = "Cancel", + onButtonClick = { displayDialog = false }, + content = { + Text( + "This is body text. Windows 11 marks a visual evolution of the operating system. We have evolved our design language alongside with Fluent to create a design which is human, universal and truly feels like Windows. \n" + + "\n" + + "The design principles below have guided us throughout the journey of making Windows the best-in-class implementation of Fluent.\n", + ) + } + ) + Button(onClick = { displayDialog = true }) { + Text("Show dialog") + } +} + +@Sample +@Composable +private fun LocalContentDialogSample() { + val dialog = LocalContentDialog.current + val scope = rememberCoroutineScope() + + Button(onClick = { + scope.launch { + val result = dialog.show( + size = DialogSize.Standard, + title = "This is an example dialog", + contentText = "This is body text. Windows 11 marks a visual evolution of the operating system. We have evolved our design language alongside with Fluent to create a design which is human, universal and truly feels like Windows. \n" + + "\n" + + "The design principles below have guided us throughout the journey of making Windows the best-in-class implementation of Fluent.\n", + primaryButtonText = "Confirm", + closeButtonText = "Cancel" + ) + } + }) { + Text("Show dialog") + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/settings/SettingsScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/settings/SettingsScreen.kt new file mode 100644 index 00000000..907b84b9 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/settings/SettingsScreen.kt @@ -0,0 +1,459 @@ +package com.konyaco.fluent.gallery.screen.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CutCornerShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.LocalContentColor +import com.konyaco.fluent.background.BackgroundSizing +import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.component.AccentButton +import com.konyaco.fluent.component.Button +import com.konyaco.fluent.component.CheckBox +import com.konyaco.fluent.component.ContentDialog +import com.konyaco.fluent.component.DropdownMenu +import com.konyaco.fluent.component.DropdownMenuItem +import com.konyaco.fluent.component.FlyoutContainer +import com.konyaco.fluent.component.FlyoutPlacement +import com.konyaco.fluent.component.Icon +import com.konyaco.fluent.component.MenuFlyoutContainer +import com.konyaco.fluent.component.MenuFlyoutItem +import com.konyaco.fluent.component.MenuFlyoutSeparator +import com.konyaco.fluent.component.ProgressBar +import com.konyaco.fluent.component.ProgressRing +import com.konyaco.fluent.component.ProgressRingSize +import com.konyaco.fluent.component.RadioButton +import com.konyaco.fluent.component.ScrollbarContainer +import com.konyaco.fluent.component.Slider +import com.konyaco.fluent.component.SubtleButton +import com.konyaco.fluent.component.Switcher +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.component.TextField +import com.konyaco.fluent.component.rememberScrollbarAdapter +import com.konyaco.fluent.gallery.LocalStore +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.regular.Add +import com.konyaco.fluent.icons.regular.ArrowLeft +import com.konyaco.fluent.icons.regular.Checkmark +import com.konyaco.fluent.icons.regular.ClipboardMore +import com.konyaco.fluent.icons.regular.Delete +import com.konyaco.fluent.icons.regular.Dismiss +import com.konyaco.fluent.icons.regular.List +import com.konyaco.fluent.icons.regular.Navigation +import com.konyaco.fluent.surface.Card + +@Composable +fun SettingsScreen() { + var displayDialog by remember { mutableStateOf(false) } + val density = LocalDensity.current + var scale by remember(density) { mutableStateOf(density.density) } + val store = LocalStore.current + + Column(Modifier.verticalScroll(rememberScrollState()).padding(16.dp), Arrangement.spacedBy(8.dp)) { + Controller( + scale = scale, + onScaleChange = { scale = it }, + darkMode = store.darkMode, + onDarkModeChange = { store.darkMode = it }, + acrylicPopupEnabled = store.enabledAcrylicPopup, + onAcrylicPopupChange = { store.enabledAcrylicPopup = it }, + compactModeEnabled = store.compactMode, + onCompactModeChange = { store.compactMode = it } + ) + + CompositionLocalProvider(LocalDensity provides Density(scale)) { + Content() + } + + AccentButton(onClick = { + displayDialog = true + }) { Text("Display Dialog") } + + Box { + var expanded by remember { mutableStateOf(false) } + + Button(onClick = { + expanded = true + }) { + Text("Show DropdownMenu") + } + + fun close() { + expanded = false + } + + DropdownMenu(expanded, ::close) { + DropdownMenuItem(::close) { Text("Option 1") } + DropdownMenuItem(::close) { Text("Option 2") } + DropdownMenuItem(::close) { Text("Option 3") } + } + } + var currentPlacement by remember { + mutableStateOf(FlyoutPlacement.Auto) + } + Row { + + FlyoutContainer( + flyout = { + Text("this is a flyout") + }, + placement = currentPlacement, + content = { + Button( + onClick = { isFlyoutVisible = true } + ) { + Text("Open Flyout") + } + } + ) + Spacer(Modifier.width(8.dp)) + Box { + var isFlyoutPlacementDropdownMenuOpened by remember { + mutableStateOf(false) + } + Button(onClick = { + isFlyoutPlacementDropdownMenuOpened = true + }) { + Text("Flyout placement") + } + val item = @Composable { placement: FlyoutPlacement -> + DropdownMenuItem({ + currentPlacement = placement + isFlyoutPlacementDropdownMenuOpened = false + }) { + Icon( + Icons.Default.Checkmark, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + .alpha(if (placement == currentPlacement) 1f else 0f) + ) + Text(text = placement.toString()) + } + } + DropdownMenu( + isFlyoutPlacementDropdownMenuOpened, + { isFlyoutPlacementDropdownMenuOpened = false }) { + FlyoutPlacement.entries.forEach { item(it) } + } + } + } + + MenuFlyoutContainer( + placement = currentPlacement, + flyout = { + MenuFlyoutItem( + onClick = { + + }, + icon = { + Icon(Icons.Default.Delete, contentDescription = null) + }, + text = { + Text("Delete") + } + ) + MenuFlyoutSeparator() + MenuFlyoutItem( + onClick = { + + }, + icon = { + Icon(Icons.Default.Add, contentDescription = null) + }, + text = { + Text("Add") + } + ) + MenuFlyoutSeparator() + MenuFlyoutItem( + onClick = {}, + icon = {}, + text = { Text("Test") } + ) + MenuFlyoutItem( + items = { + MenuFlyoutItem( + onClick = { + + }, + icon = { + Icon(Icons.Default.Add, contentDescription = null) + }, + text = { + Text("Add") + } + ) + }, + icon = { + Icon(Icons.Default.ClipboardMore, contentDescription = null) + }, + text = { + Text("More") + } + ) + }, + content = { + Button( + onClick = { isFlyoutVisible = !isFlyoutVisible } + ) { + Text("Open MenuFlyout") + } + } + ) + } + + ContentDialog( + title = "This is an example dialog", + visible = displayDialog, + primaryButtonText = "Confirm", + closeButtonText = "Cancel", + onButtonClick = { displayDialog = false }, + content = { + Text( + "This is body text. Windows 11 marks a visual evolution of the operating system. We have evolved our design language alongside with Fluent to create a design which is human, universal and truly feels like Windows. \n" + + "\n" + + "The design principles below have guided us throughout the journey of making Windows the best-in-class implementation of Fluent.\n" + ) + } + ) +} + + +@Composable +private fun Controller( + scale: Float, + onScaleChange: (Float) -> Unit, + darkMode: Boolean, + onDarkModeChange: (Boolean) -> Unit, + acrylicPopupEnabled: Boolean, + onAcrylicPopupChange: (Boolean) -> Unit, + compactModeEnabled: Boolean, + onCompactModeChange: (Boolean) -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Scale: %.2f".format(scale)) + val density = LocalDensity.current + Button(onClick = { onScaleChange(density.density) }) { Text("Reset") } + Switcher(darkMode, text = "Dark Mode", onCheckStateChange = { onDarkModeChange(it) }) + Switcher(acrylicPopupEnabled, text = "Acrylic Popup", onCheckStateChange = { onAcrylicPopupChange(it) }) + Switcher(compactModeEnabled, text = "Compact Mode", onCheckStateChange = { onCompactModeChange(it) }) + } + Slider( + modifier = Modifier.width(200.dp), + value = scale, + onValueChange = { onScaleChange(it) }, + valueRange = 1f..10f + ) +} + +@Composable +private fun Content() { + + var sliderValue by remember { mutableStateOf(0.5f) } + Slider( + modifier = Modifier.width(200.dp), + value = sliderValue, + onValueChange = { sliderValue = it }, + ) + Buttons() + + Controls() + + val layerScrollState = rememberScrollState() + ScrollbarContainer( + adapter = rememberScrollbarAdapter(layerScrollState), + isVertical = false + ) { + Row(modifier = Modifier.padding(bottom = 8.dp).horizontalScroll(layerScrollState)) { + Box { + Box(Modifier.size(32.dp).background(FluentTheme.colors.fillAccent.default)) + } + + Layer( + shape = RoundedCornerShape(4.dp), + color = FluentTheme.colors.fillAccent.default, + border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default), + content = { + Box(Modifier.size(32.dp)) + }, + backgroundSizing = BackgroundSizing.InnerBorderEdge + ) + Layer( + shape = RoundedCornerShape(4.dp), + color = FluentTheme.colors.fillAccent.default, + border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default), + content = { + Box(Modifier.size(32.dp)) + }, + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) + + Layer( + shape = CutCornerShape(4.dp), + color = FluentTheme.colors.fillAccent.default, + border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default.copy(0.5f)), + content = { + Box(Modifier.size(32.dp)) + }, + backgroundSizing = BackgroundSizing.InnerBorderEdge + ) + Layer( + shape = CutCornerShape(4.dp), + color = FluentTheme.colors.fillAccent.default, + border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default.copy(0.5f)), + content = { + Box(Modifier.size(32.dp)) + }, + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) + + Layer( + shape = CircleShape, + color = FluentTheme.colors.fillAccent.default, + border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default), + content = { + Box(Modifier.size(32.dp)) + }, + backgroundSizing = BackgroundSizing.InnerBorderEdge + ) + Layer( + shape = CircleShape, + color = FluentTheme.colors.fillAccent.default, + border = BorderStroke(1.dp, FluentTheme.colors.stroke.control.default), + content = { + Box(Modifier.size(32.dp)) + }, + backgroundSizing = BackgroundSizing.OuterBorderEdge + ) + + Card(Modifier) { + Box(Modifier.size(32.dp)) + } + } + } + var value by remember { mutableStateOf(TextFieldValue("Hello Fluent!")) } + TextField(value, onValueChange = { value = it }) + TextField( + value = value, onValueChange = { value = it }, enabled = false, + header = { Text("With Header") } + ) + + // ProgressRings + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ProgressRing(size = ProgressRingSize.Medium) + ProgressRing(progress = sliderValue) + AccentButton(onClick = {}) { + ProgressRing(size = ProgressRingSize.Small, color = LocalContentColor.current) + Text("Small") + } + } + + ProgressBar(sliderValue) + ProgressBar() + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (imageVector in icons) { + Icon( + modifier = Modifier.size(18.dp), + imageVector = imageVector, contentDescription = null + ) + } + } +} + +@Composable +private fun Controls() { + var checked by remember { mutableStateOf(false) } + Switcher(checked, text = null, onCheckStateChange = { checked = it }) + + var checked2 by remember { mutableStateOf(true) } + Switcher(checked2, text = "With Label", onCheckStateChange = { checked2 = it }) + + var checked3 by remember { mutableStateOf(true) } + Switcher( + checked3, + text = "Before Label", + textBefore = true, + onCheckStateChange = { checked3 = it } + ) + + var checked4 by remember { mutableStateOf(false) } + CheckBox(checked4) { checked4 = it } + + var checked5 by remember { mutableStateOf(true) } + CheckBox(checked5, label = "With Label") { checked5 = it } + + var selectedRadio by remember { mutableStateOf(0) } + RadioButton(selectedRadio == 0, onClick = { selectedRadio = 0 }) + RadioButton(selectedRadio == 1, onClick = { selectedRadio = 1 }, label = "With Label") +} + +@Composable +private fun Buttons() { + var text by remember { mutableStateOf("Hello World") } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val onClick = { text = "Hello, Fluent Design!" } + Button(onClick) { Text(text) } + + AccentButton(onClick) { + Icon(Icons.Default.Checkmark, contentDescription = null) + Text(text) + } + + SubtleButton(onClick) { + Text("Text Button") + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AccentButton({}, iconOnly = true) { + Icon(Icons.Default.Navigation, contentDescription = null) + } + Button({}, iconOnly = true) { + Icon(Icons.Default.Navigation, contentDescription = null) + } + SubtleButton({}, iconOnly = true) { + Icon(Icons.Default.Navigation, contentDescription = null) + } + } +} + +private val icons = arrayOf( + Icons.Default.Add, + Icons.Default.Delete, + Icons.Default.Dismiss, + Icons.Default.ArrowLeft, + Icons.Default.Navigation, + Icons.Default.List +) \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/status/ProgressBarScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/status/ProgressBarScreen.kt new file mode 100644 index 00000000..da464f8c --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/status/ProgressBarScreen.kt @@ -0,0 +1,55 @@ +package com.konyaco.fluent.gallery.screen.status + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.konyaco.fluent.component.ProgressBar +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component( + index = 2, + description = "Shows the apps progress on a task, or that the app is performing ongoing work that doesn't block user interaction." +) +@Composable +fun ProgressBarScreen() { + GalleryPage( + title = "ProgressBar", + description = "The ProgressBar has two different visual representations:\n" + + "Indeterminate - shows that a task is ongoing, but doesn't block user interaction.\n" + + "Determinate - shows how much progress has been made on a known amount of work.", + componentPath = FluentSourceFile.ProgressBar, + galleryPath = ComponentPagePath.ProgressBarScreen + ) { + Section( + title = "An indeterminate progress bar.", + sourceCode = sourceCodeOfProgressBarSample, + content = { ProgressBarSample() } + ) + Section( + title = "A determinate progress bar.", + sourceCode = sourceCodeOfDeterminateProgressBarSample, + content = { DeterminateProgressBarSample() } + ) + } + +} + +@Sample +@Composable +private fun ProgressBarSample() { + ProgressBar() +} + +@Sample +@Composable +private fun DeterminateProgressBarSample() { + var progress by remember { mutableStateOf(0.5f) } + ProgressBar(progress) + // TODO: Use NumberBox to change progress +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/status/ProgressRingScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/status/ProgressRingScreen.kt new file mode 100644 index 00000000..3e9a98ac --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/status/ProgressRingScreen.kt @@ -0,0 +1,54 @@ +package com.konyaco.fluent.gallery.screen.status + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.konyaco.fluent.component.ProgressRing +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component( + index = 2, + description = "Shows the apps progress on a task, or that the app is performing ongoing work that does block user interaction." +) +@Composable +fun ProgressRingScreen() { + GalleryPage( + title = "ProgressRing", + description = "The ProgressRing has two different visual representations:\n" + + "Indeterminate - shows that a task is ongoing, but blocks user interaction.\n" + + "Determinate - shows how much progress has been made on a known amount of work.", + componentPath = FluentSourceFile.ProgressRing, + galleryPath = ComponentPagePath.ProgressRingScreen + ) { + Section( + title = "An indeterminate progress ring.", + sourceCode = sourceCodeOfProgressRingSample, + content = { ProgressRingSample() } + ) + Section( + title = "A determinate progress ring.", + sourceCode = sourceCodeOfDeterminateProgressRingSample, + content = { DeterminateProgressRingSample() } + ) + } +} + +@Sample +@Composable +private fun ProgressRingSample() { + ProgressRing() +} + +@Sample +@Composable +private fun DeterminateProgressRingSample() { + var progress by remember { mutableStateOf(0.5f) } + ProgressRing(progress) + // TODO: Use NumberBox to change progress +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/styles/AcylicContainerScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/styles/AcylicContainerScreen.kt new file mode 100644 index 00000000..3a27c14a --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/styles/AcylicContainerScreen.kt @@ -0,0 +1,78 @@ +@file:OptIn(ExperimentalFluentApi::class) + +package com.konyaco.fluent.gallery.screen.styles + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.UrlAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withAnnotation +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.ExperimentalFluentApi +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.background.Acrylic +import com.konyaco.fluent.background.AcrylicContainer +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@OptIn(ExperimentalTextApi::class) +@Component( + index = 3, + description = "A translucent material recommended for panel backgrounds.", +) +@Composable +fun AcrylicContainerScreen() { + val linkTextColor = FluentTheme.colors.text.accent.primary + GalleryPage( + title = AnnotatedString("AcrylicContainer"), + description = buildAnnotatedString { + append("A translucent material recommended for panel backgrounds. ") + append("supported by ") + withAnnotation(UrlAnnotation("https://github.com/chrisbanes/haze")) { + append(AnnotatedString("haze", spanStyle = SpanStyle(color = linkTextColor))) + } + append(".") + }, + componentPath = FluentSourceFile.Acrylic, + galleryPath = ComponentPagePath.AcrylicContainerScreen + ) { + Section( + title = "A Basic Acrylic sample", + sourceCode = sourceCodeOfBasicAcrylicSample, + content = { BasicAcrylicSample() } + ) + } +} + +@Sample +@Composable +private fun BasicAcrylicSample() { + AcrylicContainer { + Box(modifier = Modifier.defaultMinSize(minWidth = 360.dp).height(250.dp).behindAcrylic()) { + Box(Modifier.align(Alignment.TopStart).size(100.dp, 200.dp).background(Color.Cyan)) + Box( + Modifier.align(Alignment.Center).size(152.dp) + .background(Color.Magenta, shape = CircleShape) + ) + Box(Modifier.align(Alignment.BottomEnd).size(80.dp, 100.dp).background(Color.Yellow)) + } + Acrylic(modifier = Modifier.align(Alignment.Center)) { + Box(Modifier.width(336.dp).height(226.dp)) + } + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/text/TextBlockScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/text/TextBlockScreen.kt new file mode 100644 index 00000000..127dcca5 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/text/TextBlockScreen.kt @@ -0,0 +1,50 @@ +package com.konyaco.fluent.gallery.screen.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontFamily +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component( + index = 5, + description = "A lightweight control for displaying small amounts of text." +) +@Composable +fun TextBlockScreen() { + GalleryPage( + title = "TextBlock", + description = "TextBlock is the primary control for displaying read-only text in your app. " + + "You typically display text by setting the Text property to a simple string. " + + "You can also display a series of strings in Run elements and give each different formatting.", + componentPath = FluentSourceFile.Text, + galleryPath = ComponentPagePath.TextBlockScreen + ) { + Section( + title = "A simple TextBox.", + sourceCode = sourceCodeOfSimpleTextBlockSample, + content = { SimpleTextBlockSample() } + ) + Section( + title = "A TextBlock with a style applied.", + sourceCode = sourceCodeOfStyledTextBlockSample, + content = { StyledTextBlockSample() } + ) + } + +} + +@Sample +@Composable +private fun SimpleTextBlockSample() { + Text("I am a TextBlock.") +} + +@Sample +@Composable +private fun StyledTextBlockSample() { + Text("I am a styled TextBlock", fontFamily = FontFamily.Cursive) +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/text/TextBoxScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/text/TextBoxScreen.kt new file mode 100644 index 00000000..6cde3e85 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/text/TextBoxScreen.kt @@ -0,0 +1,60 @@ +package com.konyaco.fluent.gallery.screen.text + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.component.TextField +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component( + index = 6, + description = "A single-line or multi-line plain text field." +) +@Composable +fun TextBoxScreen() { + GalleryPage( + title = "TextBox", + description = "Use a TextBox to let a user enter simple text input in your app. You can add a header and placeholder text to let the user know that the TextBox is for, and you can customize it in other ways.", + componentPath = FluentSourceFile.TextField, + galleryPath = ComponentPagePath.TextBoxScreen + ) { + Section( + title = "A simple TextBox.", + sourceCode = sourceCodeOfTextBoxSample, + content = { TextBoxSample() } + ) + Section( + title = "A TextBox with a header and placeholder text.", + sourceCode = sourceCodeOfTextBoxHeaderSample, + content = { TextBoxHeaderSample() } + ) + } +} + +@Sample +@Composable +private fun TextBoxSample() { + var value by remember { mutableStateOf(TextFieldValue()) } + TextField(value, onValueChange = { value = it }) +} + +@Sample +@Composable +private fun TextBoxHeaderSample() { + var value by remember { mutableStateOf(TextFieldValue()) } + TextField( + value = value, + onValueChange = { value = it }, + header = { Text("Enter your name:") }, + // placeholder = { Text("Name") }, + ) + // TODO: Support placeholder +} \ No newline at end of file diff --git a/gallery/src/desktopMain/kotlin/com/konyaco/fluent/gallery/Main.kt b/gallery/src/desktopMain/kotlin/com/konyaco/fluent/gallery/Main.kt index 5891f7a6..f5f64993 100644 --- a/gallery/src/desktopMain/kotlin/com/konyaco/fluent/gallery/Main.kt +++ b/gallery/src/desktopMain/kotlin/com/konyaco/fluent/gallery/Main.kt @@ -1,21 +1,29 @@ package com.konyaco.fluent.gallery import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.mayakapps.compose.windowstyler.WindowBackdrop import com.mayakapps.compose.windowstyler.WindowStyle -import org.jetbrains.skiko.hostOs +import fluentdesign.gallery.generated.resources.Res +import fluentdesign.gallery.generated.resources.icon +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.painterResource +@OptIn(ExperimentalResourceApi::class) fun main() = application { Window( onCloseRequest = ::exitApplication, - state = rememberWindowState(position = WindowPosition(Alignment.Center)), - title = "Compose Fluent Design Gallery" + state = rememberWindowState(position = WindowPosition(Alignment.Center), size = DpSize(1280.dp, 720.dp)), + title = "Compose Fluent Design Gallery", + icon = painterResource(Res.drawable.icon) ) { - GalleryTheme(displayMicaLayer = !hostOs.isWindows) { + GalleryTheme { + //TODO Make Window transparent. WindowStyle( isDarkTheme = LocalStore.current.darkMode, backdropType = WindowBackdrop.Mica diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 84599c0a..ed7a7469 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,19 @@ [versions] -kotlin = "1.9.0" -compose = "1.5.0" -androidGradlePlugin = "7.4.2" -androidBuildTools = "27.2.0-alpha16" +activityCompose = "1.8.2" +haze = "0.6.2" +kotlin = "1.9.23" +ksp = "1.9.23-1.0.20" +compose = "1.6.10" +androidGradlePlugin = "8.3.2" +androidBuildTools = "31.3.2" +windowStyler = "0.3.3-SNAPSHOT" +highlights = "0.8.0" [libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-test-junit = "androidx.test.ext:junit:1.1.5" +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } uuid = "com.benasher44:uuid:0.8.2" ktor-client-java = "io.ktor:ktor-client-java:2.2.1" @@ -15,10 +22,13 @@ google-guava = "com.google.guava:guava:31.1-jre" squareup-kotlinpoet = "com.squareup:kotlinpoet:1.12.0" android-tools-common = { module = "com.android.tools:common", version.ref = "androidBuildTools" } android-tools-sdk-common = { module = "com.android.tools:sdk-common", version.ref = "androidBuildTools" } +window-styler = { module = "com.mayakapps.compose:window-styler", version.ref = "windowStyler" } +highlights = { module = "dev.snipme:highlights", version.ref = "highlights" } [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } compose = { id = "org.jetbrains.compose", version.ref = "compose" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661e..e411586a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index ad93f0b5..65b823ed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,3 +13,5 @@ includeBuild("build-plugin") include("fluent", "fluent-icons-core", "fluent-icons-extended") include("fluent-icons-generator") include("gallery") +include("gallery-processor") +include("source-generated", "source-generated-processor") diff --git a/source-generated-processor/.gitignore b/source-generated-processor/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/source-generated-processor/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/source-generated-processor/build.gradle.kts b/source-generated-processor/build.gradle.kts new file mode 100644 index 00000000..ffafa78f --- /dev/null +++ b/source-generated-processor/build.gradle.kts @@ -0,0 +1,20 @@ +import com.konyaco.fluent.plugin.build.BuildConfig + +plugins { + alias(libs.plugins.kotlin.multiplatform) +} + +group = BuildConfig.group +version = BuildConfig.libraryVersion + +kotlin { + jvm() + sourceSets { + val jvmMain by getting { + dependencies { + implementation(libs.squareup.kotlinpoet) + implementation("com.google.devtools.ksp:symbol-processing-api:${libs.versions.ksp.get()}") + } + } + } +} \ No newline at end of file diff --git a/source-generated-processor/src/jvmMain/kotlin/com/konyaco/fluent/generated/CommonProcessorProvider.kt b/source-generated-processor/src/jvmMain/kotlin/com/konyaco/fluent/generated/CommonProcessorProvider.kt new file mode 100644 index 00000000..a678fbce --- /dev/null +++ b/source-generated-processor/src/jvmMain/kotlin/com/konyaco/fluent/generated/CommonProcessorProvider.kt @@ -0,0 +1,36 @@ +package com.konyaco.fluent.generated + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated + +class CommonProcessorProvider: SymbolProcessorProvider { + + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return CommonProcessor(environment) + } +} + +class CommonProcessor(environment: SymbolProcessorEnvironment): IProcessor { + + private val processors = listOf( + SourceFilePathProcessor(environment), + IconSourceProcessor(environment) + ) + + override fun process(resolver: Resolver): List { + return processors.flatMap { it.process(resolver) } + } + + override fun finish() { + super.finish() + processors.forEach { it.finish() } + } + + override fun onError() { + super.onError() + processors.forEach { it.onError() } + } +} \ No newline at end of file diff --git a/source-generated-processor/src/jvmMain/kotlin/com/konyaco/fluent/generated/IProcessor.kt b/source-generated-processor/src/jvmMain/kotlin/com/konyaco/fluent/generated/IProcessor.kt new file mode 100644 index 00000000..96cd67b0 --- /dev/null +++ b/source-generated-processor/src/jvmMain/kotlin/com/konyaco/fluent/generated/IProcessor.kt @@ -0,0 +1,5 @@ +package com.konyaco.fluent.generated + +import com.google.devtools.ksp.processing.SymbolProcessor + +interface IProcessor : SymbolProcessor \ No newline at end of file diff --git a/source-generated-processor/src/jvmMain/kotlin/com/konyaco/fluent/generated/IconSourceProcessor.kt b/source-generated-processor/src/jvmMain/kotlin/com/konyaco/fluent/generated/IconSourceProcessor.kt new file mode 100644 index 00000000..19f41649 --- /dev/null +++ b/source-generated-processor/src/jvmMain/kotlin/com/konyaco/fluent/generated/IconSourceProcessor.kt @@ -0,0 +1,126 @@ +package com.konyaco.fluent.generated + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.Modifier +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.asTypeName +import com.squareup.kotlinpoet.withIndent +import java.io.File +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets + +class IconSourceProcessor(environment: SymbolProcessorEnvironment) : IProcessor { + + private val componentName = environment.options["source.generated.module.name"] ?: "" + + private val iconSourceEnabled = + environment.options["source.generated.icon.enabled"]?.toBooleanStrictOrNull() ?: false + + private val packageName = "com.konyaco.fluent.source.generated" + private val packagePath = packageName.replace(".", "/") + + private val logger = environment.logger + + private val icons = mutableListOf() + private var rootPath = "" + + override fun process(resolver: Resolver): List { + if (iconSourceEnabled.not() || componentName.isEmpty()) return emptyList() + resolver.getAllFiles().forEach { file -> + file.declarations.forEach declaration@{ declaration -> + if (!declaration.modifiers.contains(Modifier.PUBLIC)) return@declaration + val declarationPackageName = declaration.packageName.asString() + + if (declarationPackageName.startsWith("com.konyaco.fluent.icons") && declaration is KSPropertyDeclaration) { + val typeDeclaration = declaration.type.resolve().declaration + if (typeDeclaration.qualifiedName?.asString() == "androidx.compose.ui.graphics.vector.ImageVector") { + val receiverType = declaration.extensionReceiver ?: return@declaration + val receiverName = when(receiverType.toString()) { + "Regular" -> "com.konyaco.fluent.icons.Icons.Regular" + "Filled" -> "com.konyaco.fluent.icons.Icons.Filled" + else -> "" + } + if (receiverName.startsWith("com.konyaco.fluent.icons.Icons")) { + if (rootPath.isEmpty()) { + val projectFile = File(file.filePath.substringBefore("/src/")) + rootPath = projectFile.parentFile.path + } + icons.add( + IconNode( + declarationPackageName, + declaration.simpleName.asString(), + receiverName + ) + ) + } + } + } + } + } + return emptyList() + } + + override fun finish() { + super.finish() + + icons.groupBy { it.receiver } + .forEach { (receiver, items) -> + val iconFile = FileSpec.builder(packageName, "${componentName}${receiver.substringAfterLast('.')}Items") + val propertyName = componentName.replaceFirstChar { it.lowercase() } + "Items" + val vectorClass = ClassName("androidx.compose.ui.graphics.vector", "ImageVector") + val type = List::class.asTypeName().parameterizedBy( + Pair::class.asTypeName().parameterizedBy( + String::class.asTypeName(), vectorClass + ) + ) + iconFile.addImport(vectorClass.packageName, vectorClass.simpleName) + iconFile.addImport("com.konyaco.fluent.icons", "Icons") + + val receiverPackage = receiver.substringBeforeLast(".Icons.") + val receiverName = receiver.substringAfter(receiverPackage).removePrefix(".") + val func = FunSpec.builder(propertyName) + .receiver(ClassName(receiverPackage, receiverName)) + .returns(type) + .addStatement( + CodeBlock.builder() + .addStatement("return listOf(") + .withIndent { + withIndent { + items.forEachIndexed { index, iconNode -> + if (index != items.lastIndex) { + addStatement("\"${iconNode.name}\" to ${iconNode.name},") + } else { + addStatement("\"${iconNode.name}\" to ${iconNode.name}") + } + iconFile.addImport(iconNode.packageName, iconNode.name) + } + } + } + .addStatement(")") + .build() + .toString() + ) + + iconFile.addFunction(func.build()) + if (rootPath.isNotBlank()) { + val targetDir = File(rootPath, "source-generated/src/commonMain/kotlin/$packagePath") + if (!targetDir.exists()) targetDir.mkdirs() + val targetFile = File(targetDir, "${iconFile.name}.kt") + if (!targetFile.exists()) targetFile.createNewFile() + OutputStreamWriter( + targetFile.outputStream(), + StandardCharsets.UTF_8 + ).use(iconFile.build()::writeTo) + } + } + } + + data class IconNode(val packageName: String, val name: String, val receiver: String) +} \ No newline at end of file diff --git a/source-generated-processor/src/jvmMain/kotlin/com/konyaco/fluent/generated/SourceFilePathProcessor.kt b/source-generated-processor/src/jvmMain/kotlin/com/konyaco/fluent/generated/SourceFilePathProcessor.kt new file mode 100644 index 00000000..8296ea4f --- /dev/null +++ b/source-generated-processor/src/jvmMain/kotlin/com/konyaco/fluent/generated/SourceFilePathProcessor.kt @@ -0,0 +1,74 @@ +package com.konyaco.fluent.generated + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.Modifier +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeSpec +import java.io.File +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets +import java.util.Locale + +class SourceFilePathProcessor(environment: SymbolProcessorEnvironment): IProcessor { + + private val packageName = "com.konyaco.fluent.source.generated" + private val packagePath = packageName.replace(".", "/") + + private val componentName = environment.options["source.generated.module.name"] ?: "" + private val enabled = environment.options["source.generated.module.enabled"]?.toBooleanStrictOrNull() ?: true + + private val objectName = "${componentName.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }}SourceFile" + private val sourceFileSpecBuilder = TypeSpec.objectBuilder(objectName) + private var rootPath = "" + + private val logger = environment.logger + + override fun process(resolver: Resolver): List { + if (!enabled) return emptyList() + if (componentName.isEmpty()) { + logger.error("please set module name by ksp arg `source.generated.module.name`") + } + resolver.getAllFiles().forEach { + val hasPublicDeclaration = it.declarations.any { declaration -> + !declaration.modifiers.any { modifier -> modifier == Modifier.INTERNAL || modifier == Modifier.PRIVATE } + } + if (hasPublicDeclaration) { + if (rootPath.isEmpty()) { + val file = File(it.filePath.substringBefore("/src/")) + rootPath = file.parentFile.path + } + sourceFileSpecBuilder + .addProperty( + PropertySpec.builder( + it.fileName.removeSuffix(".kt"), + String::class + ) + .addModifiers(KModifier.CONST) + .initializer("\"${it.filePath.substringAfter(rootPath.replace("\\", "/")).removePrefix("/")}\"") + .build() + ) + } + } + + return emptyList() + } + + override fun finish() { + super.finish() + if (rootPath.isNotBlank()) { + val targetDir = File(rootPath, "source-generated/src/commonMain/kotlin/$packagePath") + if (!targetDir.exists()) targetDir.mkdirs() + val targetFile = File(targetDir, "$objectName.kt") + if (!targetFile.exists()) targetFile.createNewFile() + OutputStreamWriter(targetFile.outputStream(), StandardCharsets.UTF_8).use( + FileSpec.builder( + packageName, + objectName + ).addType(sourceFileSpecBuilder.build()).build()::writeTo) + } + } +} \ No newline at end of file diff --git a/source-generated-processor/src/jvmMain/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/source-generated-processor/src/jvmMain/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 00000000..9edb68c9 --- /dev/null +++ b/source-generated-processor/src/jvmMain/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +com.konyaco.fluent.generated.CommonProcessorProvider \ No newline at end of file diff --git a/source-generated/.gitignore b/source-generated/.gitignore new file mode 100644 index 00000000..cf0952fb --- /dev/null +++ b/source-generated/.gitignore @@ -0,0 +1,2 @@ +/build +/src \ No newline at end of file diff --git a/source-generated/build.gradle.kts b/source-generated/build.gradle.kts new file mode 100644 index 00000000..4ddd01d3 --- /dev/null +++ b/source-generated/build.gradle.kts @@ -0,0 +1,39 @@ +import com.konyaco.fluent.plugin.build.BuildConfig +import com.konyaco.fluent.plugin.build.applyTargets + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose) + alias(libs.plugins.android.library) +} + +kotlin { + applyTargets(publish = false) + sourceSets { + commonMain { + dependencies { + implementation(project(":fluent-icons-core")) + implementation(project(":fluent-icons-extended")) + implementation(compose.ui) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} + +android { + namespace = "${BuildConfig.packageName}.generated" + compileSdk = BuildConfig.Android.compileSdkVersion + namespace = BuildConfig.packageName + defaultConfig { + minSdk = BuildConfig.Android.minSdkVersion + } + compileOptions { + sourceCompatibility = BuildConfig.Jvm.javaVersion + targetCompatibility = BuildConfig.Jvm.javaVersion + } +} \ No newline at end of file