Initial commit
This commit is contained in:
10
README.md
10
README.md
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
core/common/build.gradle.kts
Normal file
46
core/common/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
core/designsystem/build.gradle.kts
Normal file
22
core/designsystem/build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user