Initial commit

This commit is contained in:
2025-09-22 19:25:32 -03:00
parent dcd261a2b3
commit e0062445b6
122 changed files with 4728 additions and 19 deletions

View File

@@ -1,13 +1,11 @@
## Getting Started ## Getting Started
After using this repo as a template you have to do the following changes so you can start working on your project After using this repo as a template you have to do the following changes so you can start working on your project
----
### [settings.gradle.kts](settings.gradle.kts) ## Compose
- Change `rootProject.name` to your projects name
## Android
### [Build.gradle.kts](app/composeApp/build.gradle.kts)] ### [Build.gradle.kts](app/composeApp/build.gradle.kts)]
- Change `namespace` and `applicationId` to your projects name - Change `namespace` and `applicationId` to your projects name
### [settings.gradle.kts](settings.gradle.kts)
- Change `rootProject.name` to your projects name
## iOS ## iOS
### [Config.xcconfig](app/iosApp/Configuration/Config.xcconfig) ### [Config.xcconfig](app/iosApp/Configuration/Config.xcconfig)

View File

@@ -20,7 +20,7 @@ class ComposeMultiplatformConventionPlugin : Plugin<Project> {
androidMain { androidMain {
dependencies { dependencies {
implementation(composeDeps.preview) implementation(composeDeps.preview)
implementation(libs.findLibrary("libs.androidx.activity.compose").get()) implementation(libs.findLibrary("androidx.activity.compose").get())
} }
} }
commonMain { commonMain {

View File

@@ -12,7 +12,6 @@ class KotlinMultiplatformConventionPlugin : Plugin<Project> {
with(target) { with(target) {
with(pluginManager) { with(pluginManager) {
apply(libs.findPlugin("androidLibrary").get().get().pluginId) apply(libs.findPlugin("androidLibrary").get().get().pluginId)
apply(libs.findPlugin("androidMultiplatform").get().get().pluginId)
apply(libs.findPlugin("kotlinMultiplatform").get().get().pluginId) apply(libs.findPlugin("kotlinMultiplatform").get().get().pluginId)
apply(libs.findPlugin("kotlinSerialization").get().get().pluginId) apply(libs.findPlugin("kotlinSerialization").get().get().pluginId)
} }

View File

@@ -26,8 +26,8 @@ internal fun Project.configureKotlinAndroid(
sourceSets["main"].resources.srcDirs("src/commonMain/composeResources") sourceSets["main"].resources.srcDirs("src/commonMain/composeResources")
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_21
} }
packaging { packaging {
resources { resources {

View File

@@ -11,26 +11,36 @@ internal fun Project.configureKotlinMultiplatform(
) = extension.apply { ) = extension.apply {
applyDefaultHierarchyTemplate() applyDefaultHierarchyTemplate()
jvmToolchain(17) jvmToolchain(21)
androidTarget { androidTarget {
compilerOptions { compilerOptions {
jvmTarget.set(JvmTarget.JVM_17) jvmTarget.set(JvmTarget.JVM_21)
} }
} }
listOf(iosArm64(), iosSimulatorArm64()) listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "shared"
isStatic = true
}
}
sourceSets.all {
languageSettings {
optIn("androidx.compose.material3.ExperimentalMaterial3Api")
optIn("kotlin.time.ExperimentalTime")
}
}
sourceSets.apply { sourceSets.apply {
commonMain { commonMain {
dependencies { dependencies {
implementation(libs.findLibrary("kotlinx.coroutines.core").get()) implementation(libs.findLibrary("kotlinx.coroutines.core").get())
api(libs.findLibrary("koin.core").get())
} }
androidMain { androidMain {
dependencies { dependencies {
implementation(libs.findLibrary("koin.android").get())
implementation(libs.findLibrary("kotlinx.coroutines.android").get()) implementation(libs.findLibrary("kotlinx.coroutines.android").get())
} }
} }

View File

@@ -0,0 +1,46 @@
group = "dev.carlosmartino.core"
plugins {
alias(libs.plugins.kotlinDevKit)
alias(libs.plugins.composeDevKit)
}
kotlin {
sourceSets {
commonMain.dependencies {
api(libs.atomicfu)
api(libs.kotlinx.datetime)
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.json)
api(libs.koin.core)
api(libs.koin.compose)
api(libs.koin.compose.viewmodel)
api(libs.koin.compose.viewmodel.navigation)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.datastore.preferences.core)
implementation(libs.multiplatform.settings.core)
implementation(libs.multiplatform.settings.noarg)
implementation(libs.multiplatform.settings.coroutines)
implementation(libs.multiplatform.settings.serialization)
implementation(libs.moko.permissions)
implementation(libs.moko.permissions.compose)
implementation(libs.moko.permissions.bluetooth)
implementation(libs.moko.permissions.camera)
implementation(libs.moko.permissions.contacts)
implementation(libs.moko.permissions.gallery)
implementation(libs.moko.permissions.location)
implementation(libs.moko.permissions.microphone)
implementation(libs.moko.permissions.motion)
implementation(libs.moko.permissions.notifications)
implementation(libs.moko.permissions.storage)
}
androidMain.dependencies {
api(libs.kotlinx.coroutines.android)
api(libs.koin.android)
api(libs.multiplatform.settings.core)
}
}
}

View File

@@ -0,0 +1,6 @@
package cl.homelogic.platform.common
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual object Platform {
actual fun getType() = PlatformTypes.Android
}

View File

@@ -0,0 +1,18 @@
package cl.homelogic.platform.common.logging
import android.util.Log
internal actual fun logPlatform(
severity: Severity,
tag: String,
message: String,
) {
when (severity) {
Severity.Debug -> Log.d(tag, message)
Severity.Verbose -> Log.v(tag, message)
Severity.Info -> Log.i(tag, message)
Severity.Warn -> Log.w(tag, message)
Severity.Error -> Log.e(tag, message)
else -> {}
}
}

View File

@@ -0,0 +1,30 @@
package cl.homelogic.platform.common.settings
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.russhwolf.settings.Settings
import com.russhwolf.settings.SharedPreferencesSettings
class AndroidSettingsFactory(
private val context: Context,
) : ISettingsFactory {
override fun createSettings(name: String?): Settings {
val prefName = name ?: "secure_settings"
val masterKey = MasterKey
.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val sharedPreferences = EncryptedSharedPreferences.create(
context,
prefName,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
return SharedPreferencesSettings(sharedPreferences)
}
}

View File

@@ -0,0 +1,11 @@
package cl.homelogic.platform.common
enum class PlatformTypes {
IOS,
Android,
}
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
expect object Platform {
fun getType(): PlatformTypes
}

View File

@@ -0,0 +1,230 @@
package cl.homelogic.platform.common.logging
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
enum class Severity {
Debug,
Verbose,
Info,
Warn,
Error,
None,
}
enum class TreeRoots {
Navigation,
Koin,
AssetsParser,
DesignSystem,
Settings,
}
private data class LogEntry(
val timestamp: Instant,
val severity: Severity,
val tag: String,
val message: String,
)
private interface LogTree {
val maxEntries: Int
val logs: ArrayDeque<LogEntry>
fun log(
severity: Severity,
tag: String,
message: String,
)
fun getFormattedLogs(): String
}
private class MemoryLogTree(
override val maxEntries: Int,
) : LogTree {
override val logs = ArrayDeque<LogEntry>(maxEntries)
private val logsLock = SynchronizedObject()
override fun log(
severity: Severity,
tag: String,
message: String,
) {
synchronized(logsLock) {
if (logs.size >= maxEntries) {
logs.removeFirst()
}
logs.addLast(LogEntry(Clock.System.now(), severity, tag, message))
}
}
override fun getFormattedLogs(): String =
buildString {
synchronized(logsLock) {
logs.forEach { entry ->
appendLine(
"${entry.timestamp.toLocalDateTime(TimeZone.currentSystemDefault())} " +
"${entry.severity.name.padEnd(7)} " +
"${entry.tag}: ${entry.message}",
)
}
}
}
}
internal expect fun logPlatform(
severity: Severity,
tag: String,
message: String,
)
class Trace {
companion object {
private const val DEFAULT_LOG_MESSAGE = "The message content was empty"
private var _isDebug: Boolean = false
private var _minSeverity: Severity = Severity.None
private var _treeSize: Int = 20
private val _trees = HashMap<String, LogTree>()
fun initialize(isDebug: Boolean) {
_isDebug = isDebug
_minSeverity = if (_isDebug) Severity.Verbose else Severity.None
plantTree(TreeRoots.Navigation)
plantTree(TreeRoots.Koin)
plantTree(TreeRoots.AssetsParser)
plantTree(TreeRoots.DesignSystem)
plantTree(TreeRoots.Settings)
}
fun setLoggingSeverity(severity: Severity) {
_minSeverity = severity
}
fun setTreeSizes(size: Int) {
_treeSize = size
}
private fun plantTree(name: TreeRoots) {
_trees[name.name] = MemoryLogTree(_treeSize)
}
fun plantTree(name: String) {
_trees[name] = MemoryLogTree(_treeSize)
}
fun getTreeContent(name: String): String = _trees[name]?.getFormattedLogs() ?: "Tree '$name' not found"
private fun log(
severity: Severity,
tag: String,
message: String,
) {
if (severity == Severity.None || severity.ordinal < _minSeverity.ordinal) return
logPlatform(severity, "Trace - $tag", message)
_trees.values.forEach { tree ->
tree.log(severity, tag, message)
}
}
fun v(
tag: TreeRoots,
message: String,
) {
v(tag.name, message)
}
fun d(
tag: TreeRoots,
message: String,
) {
d(tag.name, message)
}
fun i(
tag: TreeRoots,
message: String,
) {
i(tag.name, message)
}
fun w(
tag: TreeRoots,
message: String,
) {
w(tag.name, message)
}
fun e(
tag: TreeRoots,
message: String,
) {
e(tag.name, message)
}
fun v(
tag: String,
message: String?,
) {
log(Severity.Verbose, tag, message ?: DEFAULT_LOG_MESSAGE)
}
fun d(
tag: String,
message: String?,
) {
log(Severity.Debug, tag, message ?: DEFAULT_LOG_MESSAGE)
}
fun i(
tag: String,
message: String?,
) {
log(Severity.Info, tag, message ?: DEFAULT_LOG_MESSAGE)
}
fun w(
tag: String,
message: String?,
) {
log(Severity.Warn, tag, message ?: DEFAULT_LOG_MESSAGE)
}
fun e(
tag: String,
message: String?,
) {
log(Severity.Error, tag, message ?: DEFAULT_LOG_MESSAGE)
}
fun e(
tag: String,
message: String,
throwable: Throwable,
) {
log(
Severity.Error,
tag,
(message + "\n" + throwable.message + "\n" + throwable.stackTraceToString()),
)
}
fun e(
tag: String,
message: String,
exception: Exception,
) {
log(
Severity.Error,
tag,
(message + "\n" + exception.message + "\n" + exception.stackTraceToString()),
)
}
}
}

View File

@@ -0,0 +1,24 @@
package cl.homelogic.platform.common.logging
import org.koin.core.logger.Level
import org.koin.core.logger.Logger
import org.koin.core.logger.MESSAGE
class TraceKoinLogger : Logger() {
override fun display(
level: Level,
msg: MESSAGE,
) {
when (level) {
Level.DEBUG -> Trace.d(DEFAULT_TAG, msg)
Level.INFO -> Trace.i(DEFAULT_TAG, msg)
Level.WARNING -> Trace.w(DEFAULT_TAG, msg)
Level.ERROR -> Trace.e(DEFAULT_TAG, msg)
Level.NONE -> {}
}
}
companion object {
const val DEFAULT_TAG = "Koin"
}
}

View File

@@ -0,0 +1,77 @@
package cl.homelogic.platform.common.permissions
import cl.homelogic.platform.common.permissions.PermissionStatus.Denied
import cl.homelogic.platform.common.permissions.PermissionStatus.Granted
import cl.homelogic.platform.common.permissions.PermissionStatus.NotGranted
import dev.icerock.moko.permissions.Permission
import dev.icerock.moko.permissions.PermissionState
import dev.icerock.moko.permissions.bluetooth.BluetoothAdvertisePermission
import dev.icerock.moko.permissions.bluetooth.BluetoothConnectPermission
import dev.icerock.moko.permissions.bluetooth.BluetoothLEPermission
import dev.icerock.moko.permissions.bluetooth.BluetoothScanPermission
import dev.icerock.moko.permissions.camera.CameraPermission
import dev.icerock.moko.permissions.contacts.ContactPermission
import dev.icerock.moko.permissions.gallery.GalleryPermission
import dev.icerock.moko.permissions.location.BackgroundLocationPermission
import dev.icerock.moko.permissions.location.CoarseLocationPermission
import dev.icerock.moko.permissions.location.LocationPermission
import dev.icerock.moko.permissions.microphone.RecordAudioPermission
import dev.icerock.moko.permissions.motion.MotionPermission
import dev.icerock.moko.permissions.notifications.RemoteNotificationPermission
import dev.icerock.moko.permissions.storage.StoragePermission
import dev.icerock.moko.permissions.storage.WriteStoragePermission
enum class PermissionType {
CAMERA,
GALLERY,
STORAGE,
WRITE_STORAGE,
LOCATION,
COARSE_LOCATION,
BACKGROUND_LOCATION,
BLUETOOTH_LE,
REMOTE_NOTIFICATION,
RECORD_AUDIO,
BLUETOOTH_SCAN,
BLUETOOTH_ADVERTISE,
BLUETOOTH_CONNECT,
CONTACTS,
MOTION,
;
companion object {
fun PermissionType.toMokoPermission() =
when (this) {
CAMERA -> CameraPermission
GALLERY -> GalleryPermission
STORAGE -> StoragePermission
WRITE_STORAGE -> WriteStoragePermission
LOCATION -> LocationPermission
COARSE_LOCATION -> CoarseLocationPermission
BACKGROUND_LOCATION -> BackgroundLocationPermission
BLUETOOTH_LE -> BluetoothLEPermission
REMOTE_NOTIFICATION -> RemoteNotificationPermission
RECORD_AUDIO -> RecordAudioPermission
BLUETOOTH_SCAN -> BluetoothScanPermission
BLUETOOTH_ADVERTISE -> BluetoothAdvertisePermission
BLUETOOTH_CONNECT -> BluetoothConnectPermission
CONTACTS -> ContactPermission
MOTION -> MotionPermission
}
}
}
fun PermissionState.toPermissionStatus() =
when (this) {
PermissionState.NotDetermined -> NotGranted
PermissionState.NotGranted -> NotGranted
PermissionState.Granted -> Granted
PermissionState.Denied -> Denied
PermissionState.DeniedAlways -> Denied
}
enum class PermissionStatus {
NotGranted,
Granted,
Denied,
}

View File

@@ -0,0 +1,74 @@
package cl.homelogic.platform.common.permissions.composables
import androidx.compose.foundation.layout.LayoutScopeMarker
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import cl.homelogic.platform.common.permissions.PermissionStatus
import cl.homelogic.platform.common.permissions.PermissionType
import cl.homelogic.platform.common.permissions.PermissionType.Companion.toMokoPermission
import cl.homelogic.platform.common.permissions.toPermissionStatus
import dev.icerock.moko.permissions.DeniedAlwaysException
import dev.icerock.moko.permissions.DeniedException
import dev.icerock.moko.permissions.PermissionsController
import dev.icerock.moko.permissions.RequestCanceledException
import dev.icerock.moko.permissions.compose.PermissionsControllerFactory
import dev.icerock.moko.permissions.compose.rememberPermissionsControllerFactory
@Composable
fun PermissionSystem(
content: @Composable PermissionsScope.() -> Unit,
) {
val factory: PermissionsControllerFactory = rememberPermissionsControllerFactory()
val controller: PermissionsController = remember(factory) {
factory.createPermissionsController()
}
PermissionsScopeInstance(controller).content()
}
@LayoutScopeMarker
@Immutable
interface PermissionsScope {
suspend fun requestPermission(permission: PermissionType): PermissionStatus
suspend fun isPermissionGranted(permission: PermissionType): Boolean
suspend fun getPermissionState(permission: PermissionType): PermissionStatus
fun openAppSettings()
}
internal class PermissionsScopeInstance(
private val permissionsController: PermissionsController,
) : PermissionsScope {
override suspend fun requestPermission(permission: PermissionType): PermissionStatus {
try {
permissionsController.providePermission(permission.toMokoPermission())
return PermissionStatus.Granted
} catch (deniedAlways: DeniedAlwaysException) {
return PermissionStatus.Denied
} catch (denied: DeniedException) {
return PermissionStatus.Denied
} catch (canceled: RequestCanceledException) {
return PermissionStatus.NotGranted
} catch (exception: Exception) {
return PermissionStatus.NotGranted
}
}
override suspend fun isPermissionGranted(permission: PermissionType): Boolean =
permissionsController.isPermissionGranted(
permission.toMokoPermission(),
)
override suspend fun getPermissionState(permission: PermissionType): PermissionStatus =
permissionsController
.getPermissionState(
permission.toMokoPermission(),
).toPermissionStatus()
override fun openAppSettings() {
permissionsController.openAppSettings()
}
}

View File

@@ -0,0 +1,57 @@
package cl.homelogic.platform.common.settings
import cl.homelogic.platform.common.logging.Trace
import cl.homelogic.platform.common.logging.TreeRoots
import com.russhwolf.settings.Settings
interface ISettingsSource {
fun <T> get(
key: PreferenceKey<T>,
defaultValue: T,
): T
fun <T> set(
key: PreferenceKey<T>,
value: T,
)
fun <T> remove(key: PreferenceKey<T>)
fun clear()
}
class SettingsSource(
private val settings: Settings,
) : ISettingsSource {
// TODO: add fuction to check if key exists
override fun <T> get(
key: PreferenceKey<T>,
defaultValue: T,
): T {
Trace.d(TreeRoots.Settings, "Retrieving setting: ${key.key}")
return key.getValue(settings, defaultValue)
}
override fun <T> set(
key: PreferenceKey<T>,
value: T,
) {
Trace.d(TreeRoots.Settings, "Storing setting: ${key.key} : $value")
key.setValue(settings, value)
}
override fun <T> remove(key: PreferenceKey<T>) {
Trace.d(TreeRoots.Settings, "Removing setting: ${key.key}")
settings.remove(key.key)
}
override fun clear() {
Trace.d(TreeRoots.Settings, "Deleting all settings")
settings.clear()
}
}
interface ISettingsFactory {
fun createSettings(name: String? = null): Settings
}

View File

@@ -0,0 +1,101 @@
package cl.homelogic.platform.common.settings
import com.russhwolf.settings.Settings
sealed class PreferenceKey<T>(
val key: String,
) {
abstract fun getValue(
settings: Settings,
defaultValue: T,
): T
abstract fun setValue(
settings: Settings,
value: T,
)
class StringKey(
key: String,
) : PreferenceKey<String>(key) {
override fun getValue(
settings: Settings,
defaultValue: String,
): String = settings.getString(key, defaultValue)
override fun setValue(
settings: Settings,
value: String,
) = settings.putString(key, value)
}
class IntKey(
key: String,
) : PreferenceKey<Int>(key) {
override fun getValue(
settings: Settings,
defaultValue: Int,
): Int = settings.getInt(key, defaultValue)
override fun setValue(
settings: Settings,
value: Int,
) = settings.putInt(key, value)
}
class LongKey(
key: String,
) : PreferenceKey<Long>(key) {
override fun getValue(
settings: Settings,
defaultValue: Long,
): Long = settings.getLong(key, defaultValue)
override fun setValue(
settings: Settings,
value: Long,
) = settings.putLong(key, value)
}
class FloatKey(
key: String,
) : PreferenceKey<Float>(key) {
override fun getValue(
settings: Settings,
defaultValue: Float,
): Float = settings.getFloat(key, defaultValue)
override fun setValue(
settings: Settings,
value: Float,
) = settings.putFloat(key, value)
}
class DoubleKey(
key: String,
) : PreferenceKey<Double>(key) {
override fun getValue(
settings: Settings,
defaultValue: Double,
): Double = settings.getDouble(key, defaultValue)
override fun setValue(
settings: Settings,
value: Double,
) = settings.putDouble(key, value)
}
class BooleanKey(
key: String,
) : PreferenceKey<Boolean>(key) {
override fun getValue(
settings: Settings,
defaultValue: Boolean,
): Boolean = settings.getBoolean(key, defaultValue)
override fun setValue(
settings: Settings,
value: Boolean,
) = settings.putBoolean(key, value)
}
}

View File

@@ -0,0 +1,6 @@
package cl.homelogic.platform.common
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual object Platform {
actual fun getType() = PlatformTypes.IOS
}

View File

@@ -0,0 +1,12 @@
package cl.homelogic.platform.common.logging
import platform.Foundation.NSLog
internal actual fun logPlatform(
severity: Severity,
tag: String,
message: String,
) {
if (severity == Severity.None) return
NSLog("Trace - $tag [${severity.name}]: $message")
}

View File

@@ -0,0 +1,16 @@
package cl.homelogic.platform.common.settings
import com.russhwolf.settings.NSUserDefaultsSettings
import com.russhwolf.settings.Settings
import platform.Foundation.NSUserDefaults
class IOSSettingsFactory : ISettingsFactory {
override fun createSettings(name: String?): Settings {
val userDefaults = if (name != null) {
NSUserDefaults(suiteName = name)
} else {
NSUserDefaults.standardUserDefaults
}
return NSUserDefaultsSettings(userDefaults)
}
}

View File

@@ -0,0 +1,22 @@
group = "dev.carlosmartino.designsystem"
plugins {
alias(libs.plugins.kotlinDevKit)
alias(libs.plugins.composeDevKit)
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
api(libs.insetsx)
implementation(libs.markdown)
}
}
}
compose.resources {
publicResClass = true
packageOfResClass = "dev.carlosmartino.designsystem.resources"
generateResClass = always
}

View File

@@ -0,0 +1,39 @@
package cl.homelogic.platform.designsystem.themes
import cl.homelogic.platform.common.logging.Trace
import cl.homelogic.platform.common.logging.TreeRoots
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
enum class StatusBarState {
LIGHT,
DARK,
}
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual class StatusBarManager : IStatusBarManager {
private val _coroutineScope = CoroutineScope(Dispatchers.Default)
companion object {
private val _statusBarState = MutableStateFlow(StatusBarState.LIGHT)
val state: StateFlow<StatusBarState> = _statusBarState.asStateFlow()
}
override fun lightMode() {
Trace.d(TreeRoots.DesignSystem, "Android - requesting light status bar")
_coroutineScope.launch {
_statusBarState.emit(StatusBarState.LIGHT)
}
}
override fun darkMode() {
Trace.d(TreeRoots.DesignSystem, "Android - requesting dark status bar")
_coroutineScope.launch {
_statusBarState.emit(StatusBarState.DARK)
}
}
}

View File

@@ -0,0 +1,12 @@
package cl.homelogic.platform.designsystem.components.atoms
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import cl.homelogic.platform.designsystem.foundations.Sizes
@Composable
fun BottomSafeZone() {
Spacer(modifier = Modifier.height(Sizes.ScreenInset.Bottom))
}

View File

@@ -0,0 +1,86 @@
package cl.homelogic.platform.designsystem.components.atoms
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import cl.homelogic.platform.designsystem.extensions.conditionalClickable
import cl.homelogic.platform.designsystem.extensions.scaledByFontSize
import cl.homelogic.platform.designsystem.foundations.Padding
import cl.homelogic.platform.designsystem.foundations.Shapes
import cl.homelogic.platform.designsystem.foundations.Sizes
@Composable
fun DescriptionCard(
modifier: Modifier = Modifier,
title: String,
subtitle: String = "",
onClick: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
Surface(
modifier = modifier
.padding(top = Padding.tiny, bottom = Padding.small)
.clip(Shapes.RoundCorner.regular)
.wrapContentSize(Alignment.TopCenter, false)
.conditionalClickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
onClick = onClick,
),
shape = Shapes.RoundCorner.regular,
border = BorderStroke(
width = Sizes.Borders.Regular,
color = MaterialTheme.colorScheme.outline,
),
) {
Column(
modifier = Modifier.wrapContentSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
content()
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(Padding.regular)
.height(Sizes.Containers.Small.scaledByFontSize())
.fillMaxWidth(),
) {
Text(
text = title,
textAlign = TextAlign.Start,
style = typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
textAlign = TextAlign.Start,
style = typography.labelMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}

View File

@@ -0,0 +1,20 @@
package cl.homelogic.platform.designsystem.components.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import cl.homelogic.platform.designsystem.foundations.Sizes
@Composable
fun Divider(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.height(Sizes.Stroke.Regular)
.background(MaterialTheme.colorScheme.outline),
)
}

View File

@@ -0,0 +1,856 @@
package cl.homelogic.platform.designsystem.components.atoms
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
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.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import cl.homelogic.platform.designsystem.foundations.Padding
import cl.homelogic.platform.designsystem.foundations.Shapes
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.getTextInNode
import org.intellij.markdown.flavours.gfm.GFMElementTypes
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import org.intellij.markdown.parser.MarkdownParser
@Immutable
data class MarkdownTypography(
val text: TextStyle,
val code: TextStyle,
val inlineCode: TextStyle,
val h1: TextStyle,
val h2: TextStyle,
val h3: TextStyle,
val h4: TextStyle,
val h5: TextStyle,
val h6: TextStyle,
val quote: TextStyle,
val paragraph: TextStyle,
val ordered: TextStyle,
val bullet: TextStyle,
val list: TextStyle,
val textLink: TextLinkStates,
val table: TextStyle,
)
@Immutable
data class MarkdownColors(
val codeBackground: Color,
val codeText: Color,
val dividerColor: Color,
val inlineCodeBackground: Color,
val inlineCodeText: Color,
val linkText: Color,
val tableBackground: Color,
val tableText: Color,
val text: Color,
)
@Immutable
data class TextLinkStates(
val normal: TextStyle,
val visited: TextStyle,
)
@Immutable
private data class MarkdownContentData(
val node: ASTNode,
val content: String,
val colors: MarkdownColors,
val typography: MarkdownTypography,
val level: Int = 0,
)
@Composable
fun Markdown(
modifier: Modifier = Modifier,
text: String,
colors: MarkdownColors = rememberDefaultMarkdownColors(),
typography: MarkdownTypography = rememberDefaultMarkdownTypography(),
) {
val flavour = remember { GFMFlavourDescriptor() }
val parser = remember { MarkdownParser(flavour) }
val markdownTree = remember(text) { parser.buildMarkdownTreeFromString(text) }
val markdownContent = remember(markdownTree, text, colors, typography) {
MarkdownContentData(markdownTree, text, colors, typography)
}
val annotatedStringCache = remember { mutableStateMapOf<String, AnnotatedString>() }
val listState = rememberLazyListState()
SelectionContainer {
Column {
OptimizedMarkdownContent(
data = markdownContent,
stringCache = annotatedStringCache,
)
}
}
}
@Composable
private fun rememberDefaultMarkdownColors(): MarkdownColors {
val colorScheme = MaterialTheme.colorScheme
return remember {
MarkdownColors(
codeBackground = colorScheme.surfaceVariant,
codeText = colorScheme.onSurfaceVariant,
dividerColor = colorScheme.outline,
inlineCodeBackground = colorScheme.surfaceVariant,
inlineCodeText = colorScheme.onSurfaceVariant,
linkText = colorScheme.tertiary,
tableBackground = colorScheme.surface,
tableText = colorScheme.onSurface,
text = colorScheme.onPrimary,
)
}
}
@Composable
private fun rememberDefaultMarkdownTypography(): MarkdownTypography {
val materialTypography = MaterialTheme.typography
return remember {
MarkdownTypography(
bullet = materialTypography.bodySmall,
code = materialTypography.bodyMedium,
h1 = materialTypography.headlineLarge,
h2 = materialTypography.headlineMedium,
h3 = materialTypography.headlineSmall,
h4 = materialTypography.titleLarge,
h5 = materialTypography.titleMedium,
h6 = materialTypography.bodyLarge,
inlineCode = materialTypography.bodyLarge,
list = materialTypography.bodyMedium,
ordered = materialTypography.bodyMedium,
paragraph = materialTypography.bodyMedium,
quote = materialTypography.bodyMedium.copy(
fontStyle = FontStyle.Italic,
),
table = materialTypography.bodySmall,
text = materialTypography.bodyMedium,
textLink = TextLinkStates(
normal = materialTypography.bodyMedium.copy(
textDecoration = TextDecoration.Underline,
),
visited = materialTypography.bodyMedium.copy(
textDecoration = TextDecoration.Underline,
),
),
)
}
}
@Composable
private fun OptimizedMarkdownContent(
data: MarkdownContentData,
stringCache: MutableMap<String, AnnotatedString>,
maxDepth: Int = 10,
) {
if (data.level > maxDepth) return
for (child in data.node.children) {
val nodeKey = "${child.hashCode()}-${data.level}"
val childData = remember(child, data) {
data.copy(node = child, level = data.level + 1)
}
when (child.type) {
MarkdownElementTypes.PARAGRAPH -> {
OptimizedParagraph(stringCache, nodeKey, child, data)
}
MarkdownElementTypes.ATX_1,
MarkdownElementTypes.ATX_2,
MarkdownElementTypes.ATX_3,
MarkdownElementTypes.ATX_4,
MarkdownElementTypes.ATX_5,
MarkdownElementTypes.ATX_6,
-> {
OptimizedHeading(
childData = childData,
nodeKey = nodeKey,
stringCache = stringCache,
)
}
MarkdownElementTypes.UNORDERED_LIST -> {
OptimizedUnorderedList(
childData = childData,
stringCache = stringCache,
)
}
MarkdownElementTypes.ORDERED_LIST -> {
OptimizedOrderedList(
childData = childData,
stringCache = stringCache,
)
}
MarkdownElementTypes.BLOCK_QUOTE -> {
OptimizedBlockQuote(
childData = childData,
stringCache = stringCache,
)
}
MarkdownElementTypes.CODE_FENCE,
MarkdownElementTypes.CODE_BLOCK,
-> {
OptimizedCodeBlock(childData = childData)
}
MarkdownElementTypes.HTML_BLOCK -> {
OptimizedHtmlBlock(childData = childData)
}
GFMElementTypes.TABLE -> {
OptimizedTable(
childData = childData,
stringCache = stringCache,
)
}
}
}
}
@Composable
private fun OptimizedParagraph(
stringCache: MutableMap<String, AnnotatedString>,
nodeKey: String,
child: ASTNode,
data: MarkdownContentData,
) {
val paragraphModifier = remember {
Modifier.padding(
vertical = Padding.small,
horizontal = Padding.tiny,
)
}
val annotatedString = stringCache.getOrPut(nodeKey) {
buildAnnotatedString {
renderInlineContent(child, data.content, data.colors, data.typography)
}
}
Text(
text = annotatedString,
style = data.typography.paragraph,
color = data.colors.text,
modifier = paragraphModifier,
)
}
@Composable
private fun OptimizedHeading(
childData: MarkdownContentData,
nodeKey: String,
stringCache: MutableMap<String, AnnotatedString>,
) {
val (style, topPadding, bottomPadding) = remember(childData.node.type) {
when (childData.node.type) {
MarkdownElementTypes.ATX_1 -> Triple(
childData.typography.h1,
Padding.regular,
Padding.small,
)
MarkdownElementTypes.ATX_2 -> Triple(
childData.typography.h2,
Padding.small,
Padding.small,
)
MarkdownElementTypes.ATX_3 -> Triple(
childData.typography.h3,
Padding.small,
Padding.small,
)
MarkdownElementTypes.ATX_4 -> Triple(
childData.typography.h4,
Padding.small,
Padding.tiny,
)
MarkdownElementTypes.ATX_5 -> Triple(
childData.typography.h5,
Padding.small,
Padding.tiny,
)
MarkdownElementTypes.ATX_6 -> Triple(
childData.typography.h6,
Padding.small,
Padding.tiny,
)
else -> Triple(
childData.typography.text,
Padding.small,
Padding.small,
)
}
}
val headingModifier = remember(topPadding, bottomPadding) {
Modifier.padding(top = topPadding, bottom = bottomPadding)
}
val headingText = stringCache.getOrPut(nodeKey) {
buildAnnotatedString {
for (headerChild in childData.node.children) {
if (headerChild.type != MarkdownTokenTypes.ATX_HEADER &&
headerChild.type != MarkdownTokenTypes.EOL
) {
renderInlineContent(
headerChild,
childData.content,
childData.colors,
childData.typography,
)
}
}
}
}
Text(
text = headingText,
style = style,
color = childData.colors.text,
modifier = headingModifier,
)
}
@Composable
private fun OptimizedUnorderedList(
childData: MarkdownContentData,
stringCache: MutableMap<String, AnnotatedString>,
) {
val listModifier = remember {
Modifier
.padding(vertical = Padding.small)
.padding(start = Padding.regular)
}
Column(modifier = listModifier) {
for (item in childData.node.children) {
if (item.type == MarkdownElementTypes.LIST_ITEM) {
val itemData = remember(item, childData) {
childData.copy(node = item)
}
OptimizedListItem(
itemData = itemData,
stringCache = stringCache,
isOrdered = false,
index = 0,
)
}
}
}
}
@Composable
private fun OptimizedOrderedList(
childData: MarkdownContentData,
stringCache: MutableMap<String, AnnotatedString>,
) {
val listModifier = remember {
Modifier
.padding(vertical = Padding.small)
.padding(start = Padding.regular)
}
Column(modifier = listModifier) {
childData.node.children.forEachIndexed { index, item ->
if (item.type == MarkdownElementTypes.LIST_ITEM) {
val itemData = remember(item, childData) {
childData.copy(node = item)
}
OptimizedListItem(
itemData = itemData,
stringCache = stringCache,
isOrdered = true,
index = index + 1,
)
}
}
}
}
@Composable
private fun OptimizedListItem(
itemData: MarkdownContentData,
stringCache: MutableMap<String, AnnotatedString>,
isOrdered: Boolean,
index: Int,
) {
val itemModifier = remember { Modifier.padding(vertical = Padding.tiny) }
Row(
modifier = itemModifier,
verticalAlignment = Alignment.Top,
) {
Text(
text = if (isOrdered) "$index. " else "",
style = if (isOrdered) itemData.typography.ordered else itemData.typography.bullet,
color = itemData.colors.text,
)
Column {
for (itemChild in itemData.node.children) {
val nodeKey = "${itemChild.hashCode()}-${itemData.level}"
when (itemChild.type) {
MarkdownElementTypes.PARAGRAPH -> {
val text = stringCache.getOrPut(nodeKey) {
buildAnnotatedString {
renderInlineContent(
itemChild,
itemData.content,
itemData.colors,
itemData.typography,
)
}
}
Text(
text = text,
style = itemData.typography.list,
color = itemData.colors.text,
)
}
MarkdownElementTypes.UNORDERED_LIST,
MarkdownElementTypes.ORDERED_LIST,
-> {
val childData = remember(itemChild, itemData) {
itemData.copy(node = itemChild)
}
OptimizedMarkdownContent(
data = childData,
stringCache = stringCache,
)
}
}
}
}
}
}
@Composable
private fun OptimizedBlockQuote(
childData: MarkdownContentData,
stringCache: MutableMap<String, AnnotatedString>,
) {
val blockQuoteModifier = remember { Modifier.padding(vertical = Padding.small) }
val dividerModifier = remember {
Modifier
.width(Padding.tiny)
.wrapContentHeight()
.background(childData.colors.dividerColor)
}
val contentModifier = remember { Modifier.padding(start = Padding.regular) }
Row(
modifier = blockQuoteModifier,
verticalAlignment = Alignment.Top,
) {
Box(modifier = dividerModifier)
Column(modifier = contentModifier) {
for (quoteChild in childData.node.children) {
val nodeKey = "${quoteChild.hashCode()}-${childData.level}"
if (quoteChild.type == MarkdownElementTypes.PARAGRAPH) {
val text = stringCache.getOrPut(nodeKey) {
buildAnnotatedString {
renderInlineContent(
quoteChild,
childData.content,
childData.colors,
childData.typography,
)
}
}
Text(
text = text,
style = childData.typography.quote,
color = childData.colors.text,
)
} else {
val innerChildData = remember(quoteChild, childData) {
childData.copy(node = quoteChild)
}
OptimizedMarkdownContent(
data = innerChildData,
stringCache = stringCache,
)
}
}
}
}
}
@Composable
private fun OptimizedCodeBlock(childData: MarkdownContentData) {
val codeContent = remember(childData.node, childData.content) {
when (childData.node.type) {
MarkdownElementTypes.CODE_FENCE -> {
childData.node.children
.firstOrNull { it.type == MarkdownTokenTypes.CODE_FENCE_CONTENT }
?.getTextInNode(childData.content)
?.toString() ?: ""
}
MarkdownElementTypes.CODE_BLOCK -> {
childData.node.children
.firstOrNull { it.type == MarkdownTokenTypes.CODE_LINE }
?.getTextInNode(childData.content)
?.toString() ?: ""
}
else -> ""
}
}
val codeBlockModifier = remember {
Modifier
.clip(Shapes.RoundCorner.regular)
.background(childData.colors.codeBackground)
.fillMaxWidth()
.padding(vertical = Padding.small)
}
val textModifier = remember { Modifier.padding(Padding.regular) }
Surface(
modifier = codeBlockModifier,
color = childData.colors.codeBackground,
) {
Text(
text = codeContent,
style = childData.typography.code,
color = childData.colors.codeText,
modifier = textModifier,
)
}
}
@Composable
private fun OptimizedHtmlBlock(childData: MarkdownContentData) {
val htmlContent = remember(childData.node, childData.content) {
childData.node.getTextInNode(childData.content).toString()
}
val htmlBlockModifier = remember { Modifier.padding(vertical = Padding.small) }
Text(
text = htmlContent,
style = childData.typography.text,
color = childData.colors.text,
modifier = htmlBlockModifier,
)
}
@Composable
private fun OptimizedTable(
childData: MarkdownContentData,
stringCache: MutableMap<String, AnnotatedString>,
) {
val tableModifier = remember {
Modifier
.fillMaxWidth()
.padding(vertical = Padding.small)
}
val tableContentModifier = remember { Modifier.padding(Padding.small) }
Surface(
color = childData.colors.tableBackground,
modifier = tableModifier,
) {
Column(modifier = tableContentModifier) {
for (row in childData.node.children) {
if (row.type == GFMElementTypes.HEADER ||
row.type == GFMElementTypes.ROW
) {
val rowData = remember(row, childData) {
childData.copy(node = row)
}
OptimizedTableRow(
rowData = rowData,
stringCache = stringCache,
)
if (row.type == GFMElementTypes.HEADER) {
Divider(modifier = Modifier.padding(bottom = Padding.small))
}
}
}
}
}
}
@Composable
private fun OptimizedTableRow(
rowData: MarkdownContentData,
stringCache: MutableMap<String, AnnotatedString>,
) {
val rowModifier = remember { Modifier.fillMaxWidth() }
val cellNodes = remember(rowData.node) {
rowData.node.children.filter {
it.type != GFMElementTypes.HEADER &&
it.type != GFMElementTypes.ROW &&
it.type != GFMElementTypes.TABLE
}
}
Row(
modifier = rowModifier,
horizontalArrangement = Arrangement.SpaceBetween,
) {
cellNodes.forEach { cellNode ->
val cellData = remember(cellNode, rowData) {
rowData.copy(node = cellNode)
}
val isHeader = remember(rowData.node.type) {
rowData.node.type == GFMElementTypes.HEADER
}
OptimizedTableCell(
modifier = Modifier.weight(1f),
cellData = cellData,
stringCache = stringCache,
isHeader = isHeader,
)
}
}
}
@Composable
private fun OptimizedTableCell(
modifier: Modifier = Modifier,
cellData: MarkdownContentData,
stringCache: MutableMap<String, AnnotatedString>,
isHeader: Boolean,
) {
val nodeKey = "${cellData.node.hashCode()}-${cellData.level}"
val cellModifier = remember { modifier.padding(Padding.tiny) }
val cellText = stringCache.getOrPut(nodeKey) {
buildAnnotatedString {
renderInlineContent(
cellData.node,
cellData.content,
cellData.colors,
cellData.typography,
)
}
}
val style = remember(isHeader) {
if (isHeader) cellData.typography.h4 else cellData.typography.table
}
val fontWeight = remember(isHeader) {
if (isHeader) FontWeight.Bold else FontWeight.Normal
}
Text(
text = cellText,
style = style,
color = cellData.colors.tableText,
fontWeight = fontWeight,
modifier = cellModifier,
)
}
private fun AnnotatedString.Builder.renderInlineContent(
node: ASTNode,
content: String,
colors: MarkdownColors,
typography: MarkdownTypography,
) {
for (child in node.children) {
when (child.type) {
MarkdownTokenTypes.TEXT, MarkdownTokenTypes.WHITE_SPACE -> {
append(child.getTextInNode(content))
}
MarkdownElementTypes.EMPH -> {
renderEmphasis(child, content, colors, typography)
}
MarkdownElementTypes.STRONG -> {
renderStrong(child, content, colors, typography)
}
MarkdownElementTypes.CODE_SPAN -> {
renderCodeSpan(child, content, colors)
}
MarkdownElementTypes.INLINE_LINK -> {
renderInlineLink(child, content, colors, typography)
}
MarkdownElementTypes.FULL_REFERENCE_LINK,
MarkdownElementTypes.SHORT_REFERENCE_LINK,
-> {
renderReferenceLink(child, content, colors, typography)
}
MarkdownTokenTypes.HARD_LINE_BREAK -> {
append("\n")
}
else -> {
renderInlineContent(child, content, colors, typography)
}
}
}
}
private fun AnnotatedString.Builder.renderEmphasis(
node: ASTNode,
content: String,
colors: MarkdownColors,
typography: MarkdownTypography,
) {
pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
renderInlineContent(node, content, colors, typography)
pop()
}
private fun AnnotatedString.Builder.renderStrong(
node: ASTNode,
content: String,
colors: MarkdownColors,
typography: MarkdownTypography,
) {
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
renderInlineContent(node, content, colors, typography)
pop()
}
private fun AnnotatedString.Builder.renderCodeSpan(
node: ASTNode,
content: String,
colors: MarkdownColors,
) {
pushStyle(
SpanStyle(
background = colors.inlineCodeBackground,
color = colors.inlineCodeText,
fontFamily = FontFamily.Monospace,
),
)
append("${node.getTextInNode(content).trim('`')}")
pop()
}
private fun AnnotatedString.Builder.renderInlineLink(
node: ASTNode,
content: String,
colors: MarkdownColors,
typography: MarkdownTypography,
) {
val linkText = node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_TEXT }
val linkDestination =
node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION }
val url = linkDestination?.getTextInNode(content)?.trim('(', ')')?.toString() ?: ""
val startPosition = length
pushStyle(SpanStyle(color = colors.linkText))
if (linkText != null) {
val linkTextContent = linkText.getTextInNode(content)
if (linkTextContent.isNotEmpty()) {
append(linkTextContent)
} else {
for (textChild in linkText.children) {
if (textChild.type != MarkdownTokenTypes.LBRACKET &&
textChild.type != MarkdownTokenTypes.RBRACKET
) {
renderInlineContent(textChild, content, colors, typography)
}
}
}
} else {
append(url)
}
if (length > startPosition) {
addLink(
url = LinkAnnotation.Url(url = url),
start = startPosition,
end = length,
)
}
pop()
}
private fun AnnotatedString.Builder.renderReferenceLink(
node: ASTNode,
content: String,
colors: MarkdownColors,
typography: MarkdownTypography,
) {
val linkText = node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_TEXT }
pushStyle(
SpanStyle(
color = colors.linkText,
textDecoration = TextDecoration.Underline,
),
)
if (linkText != null) {
for (textChild in linkText.children) {
if (textChild.type != MarkdownTokenTypes.LBRACKET &&
textChild.type != MarkdownTokenTypes.RBRACKET
) {
renderInlineContent(textChild, content, colors, typography)
}
}
}
pop()
}

View File

@@ -0,0 +1,36 @@
package cl.homelogic.platform.designsystem.components.atoms
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import cl.homelogic.platform.designsystem.foundations.Padding
import cl.homelogic.platform.designsystem.foundations.Sizes
@Composable
fun SectionTitle(
text: String,
modifier: Modifier = Modifier,
) {
Box(
contentAlignment = Alignment.BottomStart,
modifier = modifier
.fillMaxWidth()
.height(Sizes.Containers.Small)
.padding(bottom = Padding.small),
) {
Box {
Text(
text = text,
style = typography.headlineMedium,
overflow = TextOverflow.Visible,
)
}
}
}

View File

@@ -0,0 +1,145 @@
package cl.homelogic.platform.designsystem.components.atoms
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
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.fillMaxHeight
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.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import cl.homelogic.platform.designsystem.foundations.FeedbackType
import cl.homelogic.platform.designsystem.foundations.LocalHapticManager
import cl.homelogic.platform.designsystem.foundations.Padding
import cl.homelogic.platform.designsystem.foundations.Shapes
import cl.homelogic.platform.designsystem.foundations.Sizes
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
fun SegmentedControl(
modifier: Modifier = Modifier,
items: List<String>,
initialSelectedIndex: Int,
onSelectedIndexChange: (Int) -> Unit,
) {
var selectedIndex by remember { mutableIntStateOf(initialSelectedIndex) }
val density = LocalDensity.current
val haptic = LocalHapticManager.current
var componentWidth by remember { mutableStateOf(0) }
val itemWidth = remember(componentWidth) { componentWidth.toFloat() / items.size }
val pillOffset by animateFloatAsState(
targetValue = itemWidth * selectedIndex,
animationSpec = spring(
dampingRatio = 0.83f,
stiffness = Spring.StiffnessMediumLow,
),
label = "SegmentedControl-pillOffset",
)
Box(
modifier = modifier
.height(Sizes.Containers.Small)
.clip(Shapes.RoundCorner.regular)
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(Padding.tiny)
.onSizeChanged { size ->
if (componentWidth != size.width) {
componentWidth = size.width
}
},
) {
// Animated Pill
Box(
modifier = Modifier
.offset { IntOffset(pillOffset.roundToInt(), 0) }
.width(with(density) { itemWidth.toDp() })
.fillMaxHeight()
.shadow(Sizes.Shadows.Regular, shape = Shapes.RoundCorner.small)
.clip(Shapes.RoundCorner.small)
.background(MaterialTheme.colorScheme.primaryContainer),
)
SegmentedControlItems(items, selectedIndex) { newIndex ->
if (newIndex != selectedIndex) {
// Do a stronger vibration if the pill moves more than
val difference = abs(newIndex - selectedIndex)
if (difference < 3) {
haptic.performFeedback(FeedbackType.SegmentTick)
} else {
haptic.performFeedback(FeedbackType.LongPress)
}
}
selectedIndex = newIndex
onSelectedIndexChange(newIndex)
}
}
}
@Composable
private fun SegmentedControlItems(
items: List<String>,
selectedIndex: Int,
onSelectedIndexChange: (Int) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
items.forEachIndexed { index, item ->
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
onSelectedIndexChange(index)
}.padding(horizontal = Padding.small),
contentAlignment = Alignment.Center,
) {
Text(
text = item,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (selectedIndex == index) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
style = if (selectedIndex == index) {
typography.bodyLarge
} else {
typography.bodyMedium
},
)
}
}
}
}

View File

@@ -0,0 +1,83 @@
package cl.homelogic.platform.designsystem.components.atoms
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import cl.homelogic.platform.designsystem.extensions.clickable
import cl.homelogic.platform.designsystem.extensions.scaledByFontSize
import cl.homelogic.platform.designsystem.foundations.FeedbackType
import cl.homelogic.platform.designsystem.foundations.LocalHapticManager
import cl.homelogic.platform.designsystem.foundations.Padding
import cl.homelogic.platform.designsystem.foundations.Shapes
import cl.homelogic.platform.designsystem.foundations.Sizes
import cl.homelogic.platform.designsystem.foundations.Symbols
enum class ButtonStyle {
PRIMARY,
SECONDARY,
TERTIARY,
}
@Composable
fun SimpleButton(
modifier: Modifier = Modifier,
style: ButtonStyle = ButtonStyle.PRIMARY,
text: String = "",
leadingSymbol: Symbols? = null,
enabled: Boolean = true,
hapticFeedback: Boolean = true,
onClick: () -> Unit,
) {
val haptic = LocalHapticManager.current
val (background, textColor) = when (style) {
ButtonStyle.PRIMARY -> {
MaterialTheme.colorScheme.tertiary to MaterialTheme.colorScheme.onTertiary
}
ButtonStyle.SECONDARY -> {
MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onSurface
}
ButtonStyle.TERTIARY -> {
Color.Transparent to MaterialTheme.colorScheme.onSurface
}
}
Button(
onClick = {
if (hapticFeedback) haptic.performFeedback(FeedbackType.SegmentTick)
onClick()
},
shape = Shapes.RoundCorner.big,
enabled = enabled,
colors = ButtonDefaults.buttonColors().copy(
containerColor = background,
contentColor = textColor,
disabledContainerColor = background,
disabledContentColor = textColor,
),
modifier = modifier
.fillMaxWidth()
.height(Sizes.Buttons.Regular.scaledByFontSize())
.clickable(enabled),
) {
SymbolText(
modifier = Modifier.padding(end = Padding.small),
color = textColor,
icon = leadingSymbol,
)
Text(
text = text,
style = typography.bodyLarge,
)
}
}

View File

@@ -0,0 +1,45 @@
package cl.homelogic.platform.designsystem.components.atoms
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import cl.homelogic.platform.designsystem.extensions.conditionalClickable
import cl.homelogic.platform.designsystem.foundations.Shapes
import cl.homelogic.platform.designsystem.foundations.Sizes
@Composable
fun SimpleCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
Surface(
modifier = modifier
.wrapContentSize(Alignment.TopCenter, false)
.clip(Shapes.RoundCorner.regular)
.fillMaxWidth()
.conditionalClickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
onClick = onClick,
),
shape = Shapes.RoundCorner.regular,
border = BorderStroke(
width = Sizes.Borders.Regular,
color = MaterialTheme.colorScheme.outline,
),
) {
content()
}
}

View File

@@ -0,0 +1,42 @@
package cl.homelogic.platform.designsystem.components.atoms
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import cl.homelogic.platform.designsystem.extensions.clickable
import cl.homelogic.platform.designsystem.foundations.FeedbackType
import cl.homelogic.platform.designsystem.foundations.LocalHapticManager
@Composable
fun SimpleCheckbox(
checked: Boolean = false,
onCheckedChange: ((Boolean) -> Unit)? = {},
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val haptic = LocalHapticManager.current
Checkbox(
checked = checked,
onCheckedChange = {
if (it) {
haptic.performFeedback(FeedbackType.ToggleOn)
} else {
haptic.performFeedback(FeedbackType.ToggleOff)
}
onCheckedChange?.invoke(it)
},
modifier = modifier.clickable(enabled),
enabled = enabled,
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = MaterialTheme.colorScheme.onTertiary,
checkedBoxColor = MaterialTheme.colorScheme.tertiary,
checkedBorderColor = MaterialTheme.colorScheme.tertiary,
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
disabledBorderColor = MaterialTheme.colorScheme.tertiary,
disabledCheckedBoxColor = MaterialTheme.colorScheme.tertiary,
),
)
}

View File

@@ -0,0 +1,54 @@
package cl.homelogic.platform.designsystem.components.atoms
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import cl.homelogic.platform.designsystem.foundations.Dimens
import cl.homelogic.platform.designsystem.foundations.Padding
@Composable
fun SimpleWindow(
modifier: Modifier = Modifier.fillMaxSize(),
unbounded: Boolean = false,
topBar: @Composable () -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
val imeVisible by rememberUpdatedState(
WindowInsets.ime.getBottom(LocalDensity.current) > 0,
)
Scaffold(
topBar = topBar,
contentWindowInsets = if (!imeVisible) {
WindowInsets(
left = Dimens.at0,
top = Dimens.at0,
right = Dimens.at0,
bottom = Dimens.at0,
)
} else {
WindowInsets.ime
},
modifier = modifier,
) { padding ->
Surface(modifier = Modifier.padding(padding)) {
Column(
modifier = Modifier.padding(horizontal = Padding.big).takeIf { !unbounded }
?: Modifier,
) {
content()
BottomSafeZone()
}
}
}
}

View File

@@ -0,0 +1,26 @@
package cl.homelogic.platform.designsystem.components.atoms
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import cl.homelogic.platform.designsystem.foundations.Symbols
@Composable
fun SymbolText(
icon: Symbols? = null,
rawIcon: String = "",
style: TextStyle = typography.displaySmall,
color: Color = MaterialTheme.colorScheme.primary,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier,
text = icon?.codePoint ?: rawIcon,
color = color,
style = style,
)
}

View File

@@ -0,0 +1,78 @@
package cl.homelogic.platform.designsystem.components.molecules
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import cl.homelogic.platform.designsystem.components.atoms.SimpleCard
import cl.homelogic.platform.designsystem.components.atoms.SymbolText
import cl.homelogic.platform.designsystem.extensions.scaledByFontSize
import cl.homelogic.platform.designsystem.foundations.Dimens
import cl.homelogic.platform.designsystem.foundations.FeedbackType
import cl.homelogic.platform.designsystem.foundations.LocalHapticManager
import cl.homelogic.platform.designsystem.foundations.Padding
import cl.homelogic.platform.designsystem.foundations.Sizes
import cl.homelogic.platform.designsystem.foundations.Symbols
@Composable
fun IconShortcut(
modifier: Modifier = Modifier,
icon: Symbols? = null,
rawIcon: String = "",
title: String,
hasNotification: Boolean = false,
onClick: (() -> Unit)? = null,
) {
val haptic = LocalHapticManager.current
SimpleCard(
onClick = {
haptic.performFeedback(FeedbackType.SegmentTick)
onClick?.invoke()
},
modifier = modifier,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(Sizes.Buttons.Big.scaledByFontSize()),
) {
Row(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(start = Padding.small),
verticalAlignment = Alignment.CenterVertically,
) {
icon?.let { SymbolText(icon = icon) } ?: SymbolText(rawIcon = rawIcon)
Spacer(modifier = Modifier.width(Sizes.Spacers.Regular))
Text(
text = title,
style = typography.labelLarge,
)
}
if (hasNotification) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(Dimens.at8)
.align(Alignment.TopEnd)
.background(MaterialTheme.colorScheme.tertiary),
)
}
}
}
}

View File

@@ -0,0 +1,62 @@
package cl.homelogic.platform.designsystem.components.molecules
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import cl.homelogic.platform.designsystem.components.atoms.BottomSafeZone
import cl.homelogic.platform.designsystem.foundations.Padding
import cl.homelogic.platform.designsystem.foundations.Sizes
data class SectionedListItem(
val title: String,
val composables: List<@Composable () -> Unit>,
)
@Composable
fun LazySectionedList(
modifier: Modifier = Modifier,
items: List<SectionedListItem>,
addSeparator: Boolean,
) {
LazyColumn(
modifier = modifier,
) {
items(items) { sizeGroup ->
Section(sizeGroup.title) {
Column(modifier = Modifier.fillMaxWidth()) {
sizeGroup.composables.forEachIndexed { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = Padding.small),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
item()
}
if (index < sizeGroup.composables.size - 1 && addSeparator) {
HorizontalDivider(
thickness = Sizes.Stroke.Regular,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
}
}
item {
BottomSafeZone()
}
}
}

View File

@@ -0,0 +1,207 @@
package cl.homelogic.platform.designsystem.components.molecules
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
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 androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import cl.homelogic.platform.designsystem.components.atoms.BottomSafeZone
import cl.homelogic.platform.designsystem.components.atoms.ButtonStyle
import cl.homelogic.platform.designsystem.components.atoms.SimpleButton
import cl.homelogic.platform.designsystem.foundations.Padding
import cl.homelogic.platform.designsystem.foundations.Sizes
import kotlinx.coroutines.launch
class OnboardingNavigationContext(
val currentPage: Int,
val pageCount: Int,
private val navigateTo: suspend (Int) -> Unit,
private val restrictNavigation: (Boolean) -> Unit,
) {
val isFirstPage: Boolean get() = currentPage == 0
val isLastPage: Boolean get() = currentPage == pageCount - 1
suspend fun goToNextPage() {
if (currentPage < pageCount - 1) {
navigateTo(currentPage + 1)
}
}
suspend fun goToPreviousPage() {
if (currentPage > 0) {
navigateTo(currentPage - 1)
}
}
fun enableNavigation() {
restrictNavigation(false)
}
}
data class OnboardingSlide(
val restrictNavigation: Boolean = false,
val content: @Composable OnboardingNavigationContext.() -> Unit,
)
@Composable
fun OnboardingWindow(
modifier: Modifier = Modifier,
slides: List<OnboardingSlide>,
continueButtonText: String,
showIndicators: Boolean = true,
onFinish: () -> Unit = {},
) {
if (slides.isEmpty()) {
onFinish()
}
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { slides.size })
val isLastPage = pagerState.currentPage == slides.size - 1
val currentSlide = slides[pagerState.currentPage]
var hasRestrictedNavigation by remember(pagerState.currentPage) {
mutableStateOf(currentSlide.restrictNavigation)
}
val navigationContext = remember(pagerState.currentPage, hasRestrictedNavigation) {
OnboardingNavigationContext(
currentPage = pagerState.currentPage,
pageCount = slides.size,
navigateTo = { page -> pagerState.animateScrollToPage(page) },
restrictNavigation = { hasRestrictedNavigation = it },
)
}
Box(
modifier = modifier
.fillMaxSize()
.pointerInput(pagerState.currentPage, hasRestrictedNavigation) {
if (!hasRestrictedNavigation) {
detectHorizontalDragGestures(
onDragStart = {},
onDragEnd = {},
onDragCancel = {},
onHorizontalDrag = { change, dragAmount ->
change.consume()
if (dragAmount > 50 && !navigationContext.isFirstPage) {
coroutineScope.launch {
navigationContext.goToPreviousPage()
}
} else if (dragAmount < -50 && !navigationContext.isLastPage) {
coroutineScope.launch {
navigationContext.goToNextPage()
}
}
},
)
}
},
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.weight(1f)
.wrapContentHeight()
.fillMaxWidth(),
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
userScrollEnabled = !hasRestrictedNavigation,
) { page ->
val slideNavigationContext = remember(page, pagerState.currentPage) {
OnboardingNavigationContext(
currentPage = page,
pageCount = slides.size,
navigateTo = { targetPage -> pagerState.animateScrollToPage(targetPage) },
restrictNavigation = {
if (page == pagerState.currentPage) {
hasRestrictedNavigation = it
}
},
)
}
slides[page].content(slideNavigationContext)
}
}
if (showIndicators) {
PageIndicators(
pageCount = slides.size,
currentPage = pagerState.currentPage,
modifier = Modifier.padding(Padding.big),
)
}
SimpleButton(
modifier = Modifier
.fillMaxWidth()
.padding(Padding.big),
style = ButtonStyle.TERTIARY,
text = continueButtonText,
enabled = !hasRestrictedNavigation,
) {
coroutineScope.launch {
if (isLastPage) {
onFinish()
} else {
navigationContext.goToNextPage()
}
}
}
BottomSafeZone()
}
}
}
@Composable
private fun PageIndicators(
pageCount: Int,
currentPage: Int,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = modifier,
) {
repeat(pageCount) { index ->
val isSelected = index == currentPage
Box(
modifier = Modifier
.padding(horizontal = Padding.tiny)
.size(if (isSelected) Sizes.Dots.Regular else Sizes.Dots.Regular)
.background(
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
shape = CircleShape,
),
)
}
}
}

View File

@@ -0,0 +1,87 @@
package cl.homelogic.platform.designsystem.components.molecules
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.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.Alignment
import androidx.compose.ui.Modifier
import cl.homelogic.platform.designsystem.components.atoms.SimpleCard
import cl.homelogic.platform.designsystem.foundations.FeedbackType
import cl.homelogic.platform.designsystem.foundations.LocalHapticManager
import cl.homelogic.platform.designsystem.foundations.Padding
import cl.homelogic.platform.designsystem.foundations.Sizes
data class RadioButtonItem<T>(
val text: String,
val data: T,
)
@Composable
fun <T> RadioButtonSelector(
items: List<RadioButtonItem<T>>,
initialSelection: RadioButtonItem<T>? = null,
onItemSelected: (T) -> Unit,
modifier: Modifier = Modifier,
) {
var selectedItem by remember { mutableStateOf(initialSelection) }
val haptic = LocalHapticManager.current
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Sizes.Spacers.Regular),
) {
items.forEach { item ->
SelectorOption(
item = item,
isSelected = item == selectedItem,
onSelect = {
haptic.performFeedback(FeedbackType.SegmentTick)
selectedItem = item
onItemSelected(item.data)
},
)
}
}
}
@Composable
private fun <T> SelectorOption(
item: RadioButtonItem<T>,
isSelected: Boolean,
onSelect: () -> Unit,
) {
SimpleCard(
onClick = onSelect,
modifier = Modifier.wrapContentHeight(),
) {
Row(
modifier = Modifier
.padding(Padding.small)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.padding(start = Padding.small),
text = item.text,
style = MaterialTheme.typography.bodyMedium,
)
RadioButton(
selected = isSelected,
onClick = onSelect,
)
}
}
}

View File

@@ -0,0 +1,113 @@
package cl.homelogic.platform.designsystem.components.molecules
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import cl.homelogic.platform.designsystem.components.atoms.SymbolText
import cl.homelogic.platform.designsystem.extensions.intangible
import cl.homelogic.platform.designsystem.foundations.Padding
import cl.homelogic.platform.designsystem.foundations.Shapes
import cl.homelogic.platform.designsystem.foundations.Sizes
import cl.homelogic.platform.designsystem.foundations.Symbols
@Composable
fun SearchBar(
modifier: Modifier = Modifier,
hint: String = "",
onTextChange: (String) -> Unit = {},
onSearch: (String) -> Unit = {},
) {
var searchText by remember { mutableStateOf("") }
var isFocused by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
Box(
modifier = modifier
.fillMaxWidth()
.defaultMinSize(minHeight = Sizes.TextFields.Regular)
.clip(Shapes.RoundCorner.regular)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable {
focusRequester.requestFocus()
}.padding(horizontal = Padding.regular),
contentAlignment = Alignment.CenterStart,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
SymbolText(
icon = Symbols.IconSearch,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(Sizes.Spacers.Regular))
BasicTextField(
value = searchText,
onValueChange = { newText ->
searchText = newText
onTextChange(newText)
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Padding.regular)
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
isFocused = focusState.isFocused
},
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = {
onSearch(searchText)
focusManager.clearFocus()
},
),
decorationBox = { innerTextField ->
if (searchText.isEmpty() && !isFocused) {
Text(
text = hint,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.intangible(),
)
} else {
innerTextField()
}
},
)
}
}
}

View File

@@ -0,0 +1,15 @@
package cl.homelogic.platform.designsystem.components.molecules
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import cl.homelogic.platform.designsystem.components.atoms.SectionTitle
@Composable
fun Section(
title: String,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
SectionTitle(title, modifier)
content()
}

View File

@@ -0,0 +1,51 @@
package cl.homelogic.platform.designsystem.components.molecules
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import cl.homelogic.platform.designsystem.components.atoms.SymbolText
import cl.homelogic.platform.designsystem.foundations.FeedbackType
import cl.homelogic.platform.designsystem.foundations.LocalHapticManager
import cl.homelogic.platform.designsystem.foundations.Shapes
import cl.homelogic.platform.designsystem.foundations.Sizes
import cl.homelogic.platform.designsystem.foundations.Symbols
@Composable
fun SymbolButton(
modifier: Modifier = Modifier.size(Sizes.Buttons.Regular),
icon: Symbols,
color: Color = MaterialTheme.colorScheme.primary,
background: Color = Color.Transparent,
hapticFeedback: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val haptic = LocalHapticManager.current
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.clip(Shapes.RoundCorner.regular)
.background(background)
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
onClick = {
if (hapticFeedback) haptic.performFeedback(FeedbackType.SegmentTick)
onClick?.invoke()
},
),
contentAlignment = Alignment.Center,
) {
SymbolText(icon = icon, style = typography.displaySmall, color = color)
}
}

View File

@@ -0,0 +1,213 @@
package cl.homelogic.platform.designsystem.components.organisms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
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.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import cl.homelogic.platform.designsystem.components.molecules.SymbolButton
import cl.homelogic.platform.designsystem.extensions.requirePrecondition
import cl.homelogic.platform.designsystem.foundations.Padding
import cl.homelogic.platform.designsystem.foundations.Sizes
import cl.homelogic.platform.designsystem.foundations.Symbols
sealed class HeaderTypes {
data class Home(
val title: String,
val onSettings: () -> Unit,
) : HeaderTypes()
data class SubHome(
val title: String,
) : HeaderTypes()
data class Step(
val title: String,
val onBack: () -> Unit,
) : HeaderTypes()
data class Modal(
val onClosed: () -> Unit,
) : HeaderTypes()
data class Flex(
val title: String,
val leftShortcuts: List<FlexHeaderShortcut>,
val rightShortcuts: List<FlexHeaderShortcut>,
) : HeaderTypes() {
init {
requirePrecondition(leftShortcuts.size <= 3)
requirePrecondition(rightShortcuts.size <= 3)
}
}
}
data class FlexHeaderShortcut(
val icon: Symbols,
val onClick: () -> Unit,
)
@Composable
fun Header(
type: HeaderTypes,
showDivider: Boolean = true,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.height(Sizes.Header.Regular),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
when (type) {
is HeaderTypes.Home -> buildHomeHeader(type)
is HeaderTypes.SubHome -> buildSubHomeHeader(type)
is HeaderTypes.Step -> buildFlowHeader(type)
is HeaderTypes.Modal -> buildModalHeader(type)
is HeaderTypes.Flex -> buildFlexHeader(type)
}
if (showDivider) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(Sizes.Stroke.Regular)
.background(MaterialTheme.colorScheme.outline)
.align(Alignment.BottomStart),
)
}
}
}
}
@Composable
private fun buildHomeHeader(type: HeaderTypes.Home) {
Text(
text = type.title,
textAlign = TextAlign.Center,
style = typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Clip,
)
Row(
modifier = Modifier
.fillMaxSize()
.padding(Padding.small),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
) {
SymbolButton(
icon = Symbols.IconSettings,
onClick = type.onSettings,
)
}
}
@Composable
private fun buildSubHomeHeader(type: HeaderTypes.SubHome) {
Text(
text = type.title,
textAlign = TextAlign.Center,
style = typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Clip,
)
}
@Composable
private fun buildFlowHeader(type: HeaderTypes.Step) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(Padding.small),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
SymbolButton(
icon = Symbols.IconArrowBack,
onClick = type.onBack,
)
}
Text(
text = type.title,
textAlign = TextAlign.Center,
style = typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Clip,
)
}
@Composable
private fun buildModalHeader(type: HeaderTypes.Modal) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(Padding.small),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
SymbolButton(
icon = Symbols.IconClose,
onClick = type.onClosed,
)
}
}
@Composable
private fun buildFlexHeader(type: HeaderTypes.Flex) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(Padding.small),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
type.leftShortcuts.forEach { shortcut ->
SymbolButton(
icon = shortcut.icon,
onClick = shortcut.onClick,
)
}
}
Text(
text = type.title,
textAlign = TextAlign.Center,
style = typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Clip,
)
Row(
modifier = Modifier
.fillMaxSize()
.padding(Padding.small),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
) {
type.rightShortcuts.forEach { shortcut ->
SymbolButton(
icon = shortcut.icon,
onClick = shortcut.onClick,
)
}
}
}

View File

@@ -0,0 +1,58 @@
package cl.homelogic.platform.designsystem.components.zExperiments
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
@Composable
fun TemperatureGraph(
data: List<Float>,
modifier: Modifier = Modifier,
lineColor: Color = Color(0xFF6B7280),
) {
Canvas(modifier = modifier) {
if (data.isEmpty()) return@Canvas
val path = Path()
val points = Path()
val xStep = size.width / (data.size - 1)
val yStep = size.height / (data.maxOrNull() ?: 1f)
// Create smooth curve
path.moveTo(0f, size.height - (data.first() * yStep))
points.moveTo(0f, size.height - (data.first() * yStep))
data.forEachIndexed { index, value ->
if (index == 0) return@forEachIndexed
val x = index * xStep
val y = size.height - (value * yStep)
// Create smooth curve
val controlX1 = ((index - 1) * xStep + x) / 2f
val controlX2 = controlX1
val controlY1 = size.height - (data[index - 1] * yStep)
val controlY2 = y
path.cubicTo(controlX1, controlY1, controlX2, controlY2, x, y)
points.cubicTo(controlX1, controlY1, controlX2, controlY2, x, y)
}
// Draw the line
drawPath(
path = path,
color = lineColor,
style = Stroke(
width = 2.dp.toPx(),
cap = StrokeCap.Round,
join = StrokeJoin.Round,
),
)
}
}

View File

@@ -0,0 +1,9 @@
package cl.homelogic.platform.designsystem.components.zExperiments
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}

View File

@@ -0,0 +1,95 @@
package cl.homelogic.platform.designsystem.components.zExperiments
import androidx.compose.foundation.layout.Arrangement
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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.font.FontWeight
import androidx.compose.ui.unit.dp
import cl.homelogic.platform.designsystem.foundations.Shapes
@Composable
fun TemperatureCard(
modifier: Modifier = Modifier,
temperature: Int = 72,
percentage: Float = 2f,
temperatureData: List<Float> = emptyList(),
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
shape = Shapes.RoundCorner.regular,
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
) {
Column(
modifier = Modifier
.padding(24.dp)
.fillMaxWidth(),
) {
Text(
text = "Temperature",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = temperature.toString(),
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Now",
style = MaterialTheme.typography.bodyLarge,
color = Color.Gray,
)
Text(
text = "+${percentage.toInt()}%",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF22C55E), // Green color
)
}
Spacer(modifier = Modifier.height(32.dp))
TemperatureGraph(
data = temperatureData,
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("9AM", color = Color.Gray)
Text("12PM", color = Color.Gray)
Text("3PM", color = Color.Gray)
Text("6PM", color = Color.Gray)
Text("9PM", color = Color.Gray)
}
}
}
}

View File

@@ -0,0 +1,29 @@
package cl.homelogic.platform.designsystem.extensions
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.semantics.testTag
data class AccessibilityInfo(
val testTag: String,
val contentDescription: String,
val role: Role,
val stateDescription: String? = null,
)
fun Modifier.accessibility(
accessibilityInfo: AccessibilityInfo,
mergeDescendants: Boolean = true,
) = this.semantics(mergeDescendants) {
testTag = accessibilityInfo.testTag
contentDescription = accessibilityInfo.contentDescription
role = accessibilityInfo.role
accessibilityInfo.stateDescription?.let {
stateDescription = it
}
}

View File

@@ -0,0 +1,13 @@
package cl.homelogic.platform.designsystem.extensions
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@Suppress("BanInlineOptIn")
@OptIn(ExperimentalContracts::class)
fun requirePrecondition(value: Boolean) {
contract { returns() implies value }
if (!value) {
throw IllegalArgumentException("Shortcuts cannot exceed 3 items")
}
}

View File

@@ -0,0 +1,15 @@
package cl.homelogic.platform.designsystem.extensions
import androidx.compose.ui.graphics.Color
import kotlin.math.roundToInt
fun Color.toHexString(): String {
val red = (this.red * 255).roundToInt()
val green = (this.green * 255).roundToInt()
val blue = (this.blue * 255).roundToInt()
return "#" + red.toHexString() +
green.toHexString() + blue.toHexString()
}
private fun Int.toHexString(): String = this.toString(16).padStart(2, '0').uppercase()

View File

@@ -0,0 +1,31 @@
package cl.homelogic.platform.designsystem.extensions
import androidx.compose.ui.text.font.FontWeight
enum class FontWeightTypes(
val weight: Int,
) {
Thin(100),
ExtraLight(200),
Light(300),
Normal(400),
Medium(500),
SemiBold(600),
Bold(700),
ExtraBold(800),
Black(900),
}
fun FontWeight.toType(): FontWeightTypes =
when (this.weight) {
FontWeightTypes.Thin.weight -> FontWeightTypes.Thin
FontWeightTypes.ExtraLight.weight -> FontWeightTypes.ExtraLight
FontWeightTypes.Light.weight -> FontWeightTypes.Light
FontWeightTypes.Normal.weight -> FontWeightTypes.Normal
FontWeightTypes.Medium.weight -> FontWeightTypes.Medium
FontWeightTypes.SemiBold.weight -> FontWeightTypes.SemiBold
FontWeightTypes.Bold.weight -> FontWeightTypes.Bold
FontWeightTypes.ExtraBold.weight -> FontWeightTypes.ExtraBold
FontWeightTypes.Black.weight -> FontWeightTypes.Black
else -> FontWeightTypes.Normal
}

View File

@@ -0,0 +1,10 @@
package cl.homelogic.platform.designsystem.extensions
fun String.toUnicode(): String? =
try {
val paddedHex = this.padStart(4, '0')
val codePoint = paddedHex.toInt(16)
codePoint.toChar().toString()
} catch (e: Exception) {
null
}

View File

@@ -0,0 +1,5 @@
package cl.homelogic.platform.designsystem.extensions
import androidx.compose.runtime.Composable
fun listOfComposables(vararg composables: @Composable () -> Unit): List<@Composable () -> Unit> = composables.toList()

View File

@@ -0,0 +1,57 @@
package cl.homelogic.platform.designsystem.extensions
import androidx.compose.foundation.Indication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
fun Modifier.disable(): Modifier =
this
.alpha(0.3f)
.clickable(enabled = false) {}
.focusable(false)
.semantics {
disabled()
}.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { pointerInputChange: PointerInputChange ->
pointerInputChange.consume()
}
}
}
}
fun Modifier.clickable(status: Boolean): Modifier =
if (status) {
this
} else {
this.disable()
}
fun Modifier.intangible(): Modifier =
this
.alpha(0.3f)
fun Modifier.conditionalClickable(
onClick: (() -> Unit)?,
interactionSource: MutableInteractionSource,
indication: Indication?,
): Modifier =
if (onClick != null) {
this.clickable(
interactionSource = interactionSource,
indication = indication,
onClick = onClick,
)
} else {
this
}

View File

@@ -0,0 +1,11 @@
package cl.homelogic.platform.designsystem.extensions
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
@Composable
fun Dp.scaledByFontSize(): Dp {
val fontScale = LocalDensity.current.fontScale
return this * fontScale
}

Some files were not shown because too many files have changed in this diff Show More