diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..445dfb6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +All notable changes to the JetCo library will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0-beta] - 2026-02-12 + +### 🎉 What's New +Version bumped to 1.1.0-beta! Introducing powerful data visualization components and essential date/time pickers. + +### ✨ Added +- **Line Graph Component** - Highly customizable and animated line graph for both KMP and Android +- **Multi Line Graph Component** - Display multiple data series with smooth animations for both KMP and Android +- **Candle Stick Chart Component** - Professional candlestick charts for financial data visualization for both KMP and Android +- **Date Picker Component** - Intuitive date selection with customizable styling and animations for both KMP and Android +- **Time Picker Component** - Easy time selection with smooth animations for both KMP and Android +- **Date and Time Picker Component** - Combined date and time selection in one component for both KMP and Android + +--- + +## [1.0.0-beta.9] - 2025-12-13 + +### 🎉 What's New +Version bumped to 1.0.0-beta.9! Thank you so much for your support. + +### ✨ Added +- **AnimatedSearchBar Component** - New search component with smooth animations for both KMP and Android + +--- + +**Note**: Starting from this version, all changes will be documented in this CHANGELOG.md file. Thank you for using JetCo! 🚀 + +## How to Read This Changelog + +- **Added** for new features +- **Changed** for changes in existing functionality +- **Deprecated** for soon-to-be removed features +- **Removed** for now removed features +- **Fixed** for any bug fixes +- **Security** for security-related improvements +- **Improved** for performance or code quality enhancements + +--- + +*For more details about releases, visit our [GitHub Releases](https://github.com/developerchunk/JetCo/releases) page.* \ No newline at end of file diff --git a/JetCoKMP/.gitignore b/JetCoKMP/.gitignore index 5468d4a..f5f7ba9 100644 --- a/JetCoKMP/.gitignore +++ b/JetCoKMP/.gitignore @@ -17,27 +17,7 @@ captures !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings node_modules/ -/build -/jetco/build - -# Sensitive files gradle.properties -local.properties -*.keystore -*.jks -*.key -*.p12 -release.keystore -debug.keystore - -# Environment files -.env -.env.local -.env.production -.env.staging - -# API Keys and credentials -**/keys/ -**/secrets/ -api_keys.txt -config.properties \ No newline at end of file +/build +/gradle.properties +/jetco/build \ No newline at end of file diff --git a/JetCoKMP/composeApp/build.gradle.kts b/JetCoKMP/composeApp/build.gradle.kts index c29a5a8..f67dda9 100644 --- a/JetCoKMP/composeApp/build.gradle.kts +++ b/JetCoKMP/composeApp/build.gradle.kts @@ -63,8 +63,8 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) implementation("org.jetbrains.compose.material:material-icons-extended:1.7.3") -// implementation(project(":jetco")) - implementation(libs.ui) + implementation(project(":jetco")) +// implementation(libs.ui) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/JetCoKMP/composeApp/src/androidMain/kotlin/com/developerstring/jetco_kmp/MainActivity.kt b/JetCoKMP/composeApp/src/androidMain/kotlin/com/developerstring/jetco_kmp/MainActivity.kt index 482a455..12ad68c 100644 --- a/JetCoKMP/composeApp/src/androidMain/kotlin/com/developerstring/jetco_kmp/MainActivity.kt +++ b/JetCoKMP/composeApp/src/androidMain/kotlin/com/developerstring/jetco_kmp/MainActivity.kt @@ -11,7 +11,6 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - setContent { App() } diff --git a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/App.kt b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/App.kt index 59fe697..334cc0d 100644 --- a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/App.kt +++ b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/App.kt @@ -1,10 +1,8 @@ package com.developerstring.jetco_kmp import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* import androidx.compose.runtime.Composable -import com.developerstring.jetco_kmp.preview.CurvedCardPreview -import com.developerstring.jetco_kmp.preview.SwitchButtonKMP +import com.developerstring.jetco_kmp.preview.FloatingActionButtonPreview import org.jetbrains.compose.ui.tooling.preview.Preview @@ -12,13 +10,15 @@ import org.jetbrains.compose.ui.tooling.preview.Preview @Preview fun App() { MaterialTheme { - // StepperCompPreview() // StepperPreview() // SwitchButtonKMP() // TicketCardSimple() - CurvedCardPreview() +// CurvedCardPreview() // TicketCardCustom() +// AnimatedSearchBarPreview() +// DateTimePickerScreen() + FloatingActionButtonPreview() } } diff --git a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/AnimatedSearchBarPreview.kt b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/AnimatedSearchBarPreview.kt new file mode 100644 index 0000000..0268efc --- /dev/null +++ b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/AnimatedSearchBarPreview.kt @@ -0,0 +1,116 @@ +package com.developerstring.jetco_kmp.preview + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.search.animated_searchbar.AnimatedSearchBar +import com.developerstring.jetco_kmp.components.search.animated_searchbar.rememberAnimatedSearchBarController +import kotlinx.coroutines.launch + +@Composable +fun AnimatedSearchBarPreview() { + var query by remember { mutableStateOf("") } + val controller = rememberAnimatedSearchBarController() + var results by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + var scope = rememberCoroutineScope() + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center + ) { + AnimatedSearchBar( + value = query, + onValueChange = { query = it }, + controller = controller, + isLoading = isLoading, + onSearch = { q -> + isLoading = true + scope.launch { + // tiny delay to simulate request + kotlinx.coroutines.delay(1200) + // produce fake results + results = if (q.isBlank()) emptyList() + else List(6) { index -> "${q.trim()} result ${index + 1}" } + isLoading = false + } + }, + onExpand = { /* optional: handle expand */ }, + onCollapse = { /* optional: handle collapse */ } + + ) + + Spacer(modifier = Modifier.height(16.dp)) + + //Simulate fake results for preview + if (results.isNotEmpty()) { + Text( + "Results", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(4.dp) + ) + + HorizontalDivider() + + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(results) { item -> + Row( + modifier = Modifier.fillMaxWidth().clickable { + // When user clicks a result: + // 1) set the query text + // 2) collapse the search bar via controller + query = item + controller.collapse() + // optional: clear results if you want them hidden + results = emptyList() + }.padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(item, modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null + ) + } + HorizontalDivider() + } + } + } else { + // optional empty-state hint + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + "Type and press search to simulate results", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) + } + } + } + } +} diff --git a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/ChartsPreview.kt b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/ChartsPreview.kt index 711c436..807760b 100644 --- a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/ChartsPreview.kt +++ b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/ChartsPreview.kt @@ -138,8 +138,10 @@ fun ChartsCMPPreview() { // Custom bar design for the chart, with each bar formatted as a card barDesign = { text -> - val value = text.toDouble() - val formatted = if (value % 1 == 0.0) value.toInt().toString() else value.toString() + val value = text.toDoubleOrNull() ?: 0.0 + val formatted = if (value.isNaN() || value.isInfinite()) "0" + else if (value % 1 == 0.0) value.toInt().toString() + else value.toString() Card( modifier = Modifier.fillMaxSize(), @@ -187,8 +189,10 @@ fun ChartsCMPPreview() { }, barDesign = { text -> - val value = text.toDouble() - val formatted = if (value % 1 == 0.0) value.toInt().toString() else value.toString() + val value = text.toDoubleOrNull() ?: 0.0 + val formatted = if (value.isNaN() || value.isInfinite()) "0" + else if (value % 1 == 0.0) value.toInt().toString() + else value.toString() Card( modifier = Modifier.fillMaxSize(), @@ -250,8 +254,10 @@ fun ChartsCMPPreview() { // Custom bar design for the chart, with each bar formatted as a card barDesign = { text -> - val value = text.toDouble() - val formatted = if (value % 1 == 0.0) value.toInt().toString() else value.toString() + val value = text.toDoubleOrNull() ?: 0.0 + val formatted = if (value.isNaN() || value.isInfinite()) "0" + else if (value % 1 == 0.0) value.toInt().toString() + else value.toString() Card( modifier = Modifier.fillMaxSize(), diff --git a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/DateTimePickerScreen.kt b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/DateTimePickerScreen.kt new file mode 100644 index 0000000..f1891b4 --- /dev/null +++ b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/DateTimePickerScreen.kt @@ -0,0 +1,575 @@ +package com.developerstring.jetco_kmp.preview + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +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.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.developerstring.jetco_kmp.components.picker.WheelDatePicker +import com.developerstring.jetco_kmp.components.picker.WheelDatePickerView +import com.developerstring.jetco_kmp.components.picker.WheelDateRangePickerView +import com.developerstring.jetco_kmp.components.picker.WheelDateTimePickerView +import com.developerstring.jetco_kmp.components.picker.WheelMonthPickerView +import com.developerstring.jetco_kmp.components.picker.WheelMonthYearPickerView +import com.developerstring.jetco_kmp.components.picker.WheelTimePickerView +import com.developerstring.jetco_kmp.components.picker.WheelYearPickerView +import com.developerstring.jetco_kmp.components.picker.config.PickerDefaults +import com.developerstring.jetco_kmp.components.picker.model.DateOrder +import com.developerstring.jetco_kmp.components.picker.model.PickerDate +import com.developerstring.jetco_kmp.components.picker.model.PickerDisplayMode +import com.developerstring.jetco_kmp.components.picker.model.TimeFormat + +@Composable +fun DateTimePickerScreen() { + // State for bottom sheet pickers + var showDateBottomSheet by remember { mutableStateOf(false) } + var showTimeBottomSheet by remember { mutableStateOf(false) } + var showDateTimeBottomSheet by remember { mutableStateOf(false) } + var showDateRangeBottomSheet by remember { mutableStateOf(false) } + var showMonthYearBottomSheet by remember { mutableStateOf(false) } + var showYearBottomSheet by remember { mutableStateOf(false) } + var showMonthBottomSheet by remember { mutableStateOf(false) } + + // State for dialog pickers + var showDateDialog by remember { mutableStateOf(false) } + var showTimeDialog by remember { mutableStateOf(false) } + var showDateTimeDialog by remember { mutableStateOf(false) } + var showDateRangeDialog by remember { mutableStateOf(false) } + + // State for highly customized variants + var showCustomDateDialog by remember { mutableStateOf(false) } + var showCustomTimeBottomSheet by remember { mutableStateOf(false) } + var showCustomDateRangeDialog by remember { mutableStateOf(false) } + + // Selected values + var selectedDate by remember { mutableStateOf("Not selected") } + var selectedTime by remember { mutableStateOf("Not selected") } + var selectedDateTime by remember { mutableStateOf("Not selected") } + var selectedDateRange by remember { mutableStateOf("Not selected") } + var selectedMonthYear by remember { mutableStateOf("Not selected") } + var selectedYear by remember { mutableStateOf("Not selected") } + var selectedMonth by remember { mutableStateOf("Not selected") } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Title + Text( + text = "Date & Time Pickers", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp) + ) + + // ━━━━━━━━━━━━━━━━━━━ BOTTOM SHEET VARIANTS ━━━━━━━━━━━━━━━━━━━ + SectionCard( + title = "Bottom Sheet Pickers", + description = "Default style with Material 3 BottomSheet" + ) { + PickerButton( + text = "Date Picker", + value = selectedDate, + onClick = { showDateBottomSheet = true } + ) + + PickerButton( + text = "Time Picker (24h)", + value = selectedTime, + onClick = { showTimeBottomSheet = true } + ) + + PickerButton( + text = "Date & Time Picker", + value = selectedDateTime, + onClick = { showDateTimeBottomSheet = true } + ) + + PickerButton( + text = "Date Range Picker", + value = selectedDateRange, + onClick = { showDateRangeBottomSheet = true } + ) + + PickerButton( + text = "Month & Year Picker", + value = selectedMonthYear, + onClick = { showMonthYearBottomSheet = true } + ) + + PickerButton( + text = "Year Picker", + value = selectedYear, + onClick = { showYearBottomSheet = true } + ) + + PickerButton( + text = "Month Picker", + value = selectedMonth, + onClick = { showMonthBottomSheet = true } + ) + } + + // ━━━━━━━━━━━━━━━━━━━ DIALOG VARIANTS ━━━━━━━━━━━━━━━━━━━ + SectionCard( + title = "Dialog Pickers", + description = "Clean & compact dialog presentation" + ) { + PickerButton( + text = "Date Picker Dialog", + value = selectedDate, + onClick = { showDateDialog = true } + ) + + PickerButton( + text = "Time Picker Dialog (12h)", + value = selectedTime, + onClick = { showTimeDialog = true } + ) + + PickerButton( + text = "DateTime Dialog", + value = selectedDateTime, + onClick = { showDateTimeDialog = true } + ) + + PickerButton( + text = "Date Range Dialog", + value = selectedDateRange, + onClick = { showDateRangeDialog = true } + ) + } + + // ━━━━━━━━━━━━━━━━━━━ CUSTOMIZED VARIANTS ━━━━━━━━━━━━━━━━━━━ + SectionCard( + title = "Highly Customized Variants", + description = "Custom colors, fonts, intervals & formats" + ) { + PickerButton( + text = "Custom Date (MDY Order)", + value = selectedDate, + onClick = { showCustomDateDialog = true } + ) + + PickerButton( + text = "Custom Time (5-min intervals)", + value = selectedTime, + onClick = { showCustomTimeBottomSheet = true } + ) + + PickerButton( + text = "Custom Range (Short Months)", + value = selectedDateRange, + onClick = { showCustomDateRangeDialog = true } + ) + } + + // ━━━━━━━━━━━━━━━━━━━ INLINE VARIANT ━━━━━━━━━━━━━━━━━━━ + SectionCard( + title = "Inline Picker", + description = "Embedded directly in the UI" + ) { + WheelDatePicker( + startDate = PickerDate.now(), + wheelConfig = PickerDefaults.wheelPickerConfig(height = 180.dp), + headerConfig = PickerDefaults.headerConfig( + title = "Select Date", + enabled = true + ), + onDoneClick = { date -> + selectedDate = date.toFormattedString() + } + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } + + // ━━━━━━━━━━━━━━━━━━━ BOTTOM SHEET PICKERS ━━━━━━━━━━━━━━━━━━━ + + WheelDatePickerView( + visible = showDateBottomSheet, + displayMode = PickerDisplayMode.BOTTOM_SHEET, + startDate = PickerDate.now(), + onDoneClick = { date -> + selectedDate = date.toFormattedString() + }, + onDismiss = { showDateBottomSheet = false } + ) + + WheelTimePickerView( + visible = showTimeBottomSheet, + displayMode = PickerDisplayMode.BOTTOM_SHEET, + timeConfig = PickerDefaults.timePickerConfig( + timeFormat = TimeFormat.HOUR_24 + ), + onDoneClick = { time -> + selectedTime = time.toFormattedString(TimeFormat.HOUR_24) + }, + onDismiss = { showTimeBottomSheet = false } + ) + + WheelDateTimePickerView( + visible = showDateTimeBottomSheet, + displayMode = PickerDisplayMode.BOTTOM_SHEET, + onDoneClick = { dateTime -> + selectedDateTime = "${dateTime.date.toFormattedString()} ${dateTime.time.toFormattedString()}" + }, + onDismiss = { showDateTimeBottomSheet = false } + ) + + WheelDateRangePickerView( + visible = showDateRangeBottomSheet, + displayMode = PickerDisplayMode.BOTTOM_SHEET, + onDoneClick = { from, to -> + selectedDateRange = "${from.toFormattedString()} to ${to.toFormattedString()}" + }, + onDismiss = { showDateRangeBottomSheet = false } + ) + + WheelMonthYearPickerView( + visible = showMonthYearBottomSheet, + displayMode = PickerDisplayMode.BOTTOM_SHEET, + onDoneClick = { month, year -> + val monthNames = listOf( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + ) + selectedMonthYear = "${monthNames[month - 1]} $year" + }, + onDismiss = { showMonthYearBottomSheet = false } + ) + + WheelYearPickerView( + visible = showYearBottomSheet, + displayMode = PickerDisplayMode.BOTTOM_SHEET, + yearsRange = IntRange(2000, 2050), + onDoneClick = { year -> + selectedYear = year.toString() + }, + onDismiss = { showYearBottomSheet = false } + ) + + WheelMonthPickerView( + visible = showMonthBottomSheet, + displayMode = PickerDisplayMode.BOTTOM_SHEET, + dateConfig = PickerDefaults.datePickerConfig(showShortMonths = true), + onDoneClick = { month -> + val monthNames = listOf( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + ) + selectedMonth = monthNames[month - 1] + }, + onDismiss = { showMonthBottomSheet = false } + ) + + // ━━━━━━━━━━━━━━━━━━━ DIALOG PICKERS ━━━━━━━━━━━━━━━━━━━ + + WheelDatePickerView( + visible = showDateDialog, + displayMode = PickerDisplayMode.DIALOG, + headerConfig = PickerDefaults.headerConfig( + title = "Pick a Date", + cancelLabel = "Cancel" + ), + onDoneClick = { date -> + selectedDate = date.format("dd MMMM yyyy") + }, + onDismiss = { showDateDialog = false } + ) + + WheelTimePickerView( + visible = showTimeDialog, + displayMode = PickerDisplayMode.DIALOG, + timeConfig = PickerDefaults.timePickerConfig( + timeFormat = TimeFormat.HOUR_12, + showSeconds = false + ), + headerConfig = PickerDefaults.headerConfig( + title = "Pick Time", + cancelLabel = "Cancel" + ), + onDoneClick = { time -> + selectedTime = time.toFormattedString(TimeFormat.HOUR_12) + }, + onDismiss = { showTimeDialog = false } + ) + + WheelDateTimePickerView( + visible = showDateTimeDialog, + displayMode = PickerDisplayMode.DIALOG, + headerConfig = PickerDefaults.headerConfig( + title = "Select Date & Time", + cancelLabel = "Cancel" + ), + onDoneClick = { dateTime -> + selectedDateTime = "${dateTime.date.format("MMM dd, yyyy")} at ${dateTime.time.toFormattedString()}" + }, + onDismiss = { showDateTimeDialog = false } + ) + + WheelDateRangePickerView( + visible = showDateRangeDialog, + displayMode = PickerDisplayMode.DIALOG, + headerConfig = PickerDefaults.headerConfig( + title = "Select Date Range", + cancelLabel = "Cancel" + ), + onDoneClick = { from, to -> + selectedDateRange = "${from.format("MMM dd")} - ${to.format("MMM dd, yyyy")}" + }, + onDismiss = { showDateRangeDialog = false } + ) + + // ━━━━━━━━━━━━━━━━━━━ HIGHLY CUSTOMIZED VARIANTS ━━━━━━━━━━━━━━━━━━━ + + WheelDatePickerView( + visible = showCustomDateDialog, + displayMode = PickerDisplayMode.DIALOG, + wheelConfig = PickerDefaults.wheelPickerConfig( + height = 220.dp, + rowCount = 5, + selectedTextStyle = TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF0066CC) + ), + defaultTextStyle = TextStyle( + fontSize = 16.sp, + color = Color(0xFF999999) + ), + selectorColor = Color(0x1A0066CC), + selectorShape = RoundedCornerShape(16.dp), + fadeEdges = true + ), + headerConfig = PickerDefaults.headerConfig( + title = "Custom Date Picker", + doneLabel = "Select", + doneLabelColor = Color(0xFF0066CC), + cancelLabel = "Dismiss", + titleStyle = TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF1A1A1A) + ), + doneLabelStyle = TextStyle( + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold + ) + ), + dateConfig = PickerDefaults.datePickerConfig( + dateOrder = DateOrder.MONTH_DAY_YEAR, + showShortMonths = true, + yearsRange = IntRange(1990, 2030) + ), + containerConfig = PickerDefaults.containerConfig( + containerColor = Color(0xFFFAFAFA), + shape = RoundedCornerShape(24.dp) + ), + onDoneClick = { date -> + selectedDate = date.format("MMM dd, yyyy") + }, + onDismiss = { showCustomDateDialog = false } + ) + + WheelTimePickerView( + visible = showCustomTimeBottomSheet, + displayMode = PickerDisplayMode.BOTTOM_SHEET, + wheelConfig = PickerDefaults.wheelPickerConfig( + height = 200.dp, + selectedTextStyle = TextStyle( + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF00AA00) + ), + selectorColor = Color(0x2200AA00), + selectorShape = RoundedCornerShape(12.dp) + ), + headerConfig = PickerDefaults.headerConfig( + title = "Pick Your Time", + doneLabel = "Confirm", + doneLabelColor = Color(0xFF00AA00), + titleStyle = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold + ) + ), + timeConfig = PickerDefaults.timePickerConfig( + timeFormat = TimeFormat.HOUR_24, + showSeconds = true, + minuteInterval = 5, + secondInterval = 15 + ), + onDoneClick = { time -> + selectedTime = "${time.hour}:${time.minute.toString().padStart(2, '0')}:${time.second.toString().padStart(2, '0')}" + }, + onDismiss = { showCustomTimeBottomSheet = false } + ) + + WheelDateRangePickerView( + visible = showCustomDateRangeDialog, + displayMode = PickerDisplayMode.DIALOG, + wheelConfig = PickerDefaults.wheelPickerConfig( + height = 180.dp, + selectedTextStyle = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFFF6B35) + ), + selectorColor = Color(0x1AFF6B35) + ), + headerConfig = PickerDefaults.headerConfig( + title = "Select Trip Dates", + doneLabel = "Book", + doneLabelColor = Color(0xFFFF6B35), + cancelLabel = "Back", + titleStyle = TextStyle( + fontSize = 19.sp, + fontWeight = FontWeight.Bold + ) + ), + dateConfig = PickerDefaults.datePickerConfig( + showShortMonths = true, + dateOrder = DateOrder.DAY_MONTH_YEAR + ), + rangeConfig = PickerDefaults.dateRangePickerConfig( + centerText = "→", + selectedBoxColor = Color(0xFFFFE5DC), + selectedBoxBorderColor = Color(0xFFFF6B35), + selectedTextColor = Color(0xFFFF6B35), + boxShape = RoundedCornerShape(12.dp), + dateTextFormat = "dd MMM yyyy" + ), + containerConfig = PickerDefaults.containerConfig( + containerColor = Color.White, + shape = RoundedCornerShape(20.dp) + ), + onDoneClick = { from, to -> + selectedDateRange = "${from.format("dd MMM")} → ${to.format("dd MMM yyyy")}" + }, + onDismiss = { showCustomDateRangeDialog = false } + ) +} + +@Composable +private fun SectionCard( + title: String, + description: String, + content: @Composable () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = Color.White + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = Color(0xFF1A1A1A) + ) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF666666), + modifier = Modifier.padding(top = 4.dp, bottom = 12.dp) + ) + + HorizontalDivider( + color = Color(0xFFE0E0E0), + modifier = Modifier.padding(bottom = 12.dp) + ) + + content() + } + } +} + +@Composable +private fun PickerButton( + text: String, + value: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = onClick, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF519DE9) + ), + shape = RoundedCornerShape(8.dp) + ) { + Text(text = text) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Box( + modifier = Modifier + .weight(1f) + .background( + color = Color(0xFFF0F0F0), + shape = RoundedCornerShape(8.dp) + ) + .padding(12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + color = if (value == "Not selected") Color(0xFF999999) else Color(0xFF1A1A1A), + fontWeight = if (value == "Not selected") FontWeight.Normal else FontWeight.Medium + ) + } + } +} \ No newline at end of file diff --git a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/FloatingActionButtonPreview.kt b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/FloatingActionButtonPreview.kt new file mode 100644 index 0000000..2589c20 --- /dev/null +++ b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/FloatingActionButtonPreview.kt @@ -0,0 +1,837 @@ +package com.developerstring.jetco_kmp.preview + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +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.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +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.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.AccountBox +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Favorite +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.MailOutline +import androidx.compose.material.icons.outlined.Place +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.FabPosition +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.developerstring.jetco_kmp.components.button.fab.MorphFloatingActionButton +import com.developerstring.jetco_kmp.components.button.fab.RadialFloatingActionButton +import com.developerstring.jetco_kmp.components.button.fab.StackFloatingActionButton +import com.developerstring.jetco_kmp.components.button.fab.model.FabItem +import com.developerstring.jetco_kmp.components.button.fab.model.FabMainConfig +import com.developerstring.jetco_kmp.components.button.fab.model.FabMainConfig.Orientation +import com.developerstring.jetco_kmp.components.button.fab.model.MorphFabItem +import com.developerstring.jetco_kmp.components.button.fab.model.StackDirection +import com.developerstring.jetco_kmp.components.button.fab.model.StackExpandOffset +import com.developerstring.jetco_kmp.components.button.fab.model.StackFabItem +import com.developerstring.jetco_kmp.components.button.fab.transition.FabButtonTransition +import com.developerstring.jetco_kmp.components.button.fab.transition.FabItemTransition +import com.developerstring.jetco_kmp.components.button.fab.transition.OffsetTransition +import com.developerstring.jetco_kmp.components.button.fab.transition.ScaleTransition + +private val Orange = Color(0xFFE46212) +private val Teal = Color(0xFF3DBFE2) +private val Green = Color(0xFF4AA651) +private val Red = Color(0xFFDE3B3D) +private val Purple = Color(0xFF7C4DFF) +private val Pink = Color(0xFFE91E8C) +private val Navy = Color(0xFF1A237E) +private val Gold = Color(0xFFFFC107) + +private enum class FabVariant(val label: String) { + RADIAL_CLASSIC("Radial"), + RADIAL_FULL_ARC("Radial 2"), + STACK_VERTICAL("Stack 1"), + STACK_MIXED("Stack 2"), + STACK_HORIZONTAL("H-Stack"), + STACK_PUSH("Push"), + MORPH_GRID("Morph"), + MORPH_DETAIL("M-Detail") +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun FloatingActionButtonPreview() { + var selected by remember { mutableStateOf(FabVariant.RADIAL_FULL_ARC) } + var expandOffset by remember { mutableStateOf(StackExpandOffset()) } + + val screenOffsetY by animateDpAsState( + targetValue = -expandOffset.offsetY, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "screenPushY" + ) + val screenOffsetX by animateDpAsState( + targetValue = -expandOffset.offsetX, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "screenPushX" + ) + + Scaffold( + topBar = { + FlowRow( + maxItemsInEachRow = 4, + modifier = Modifier + .statusBarsPadding() + .fillMaxWidth() + .background(Color.White), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally) + ) { + FabVariant.entries.forEach { variant -> + FilterChip( + selected = selected == variant, + onClick = { selected = variant }, + label = { + Text( + text = variant.label, + fontSize = 11.sp, + fontWeight = if (selected == variant) FontWeight.Bold else FontWeight.Normal + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Orange, + selectedLabelColor = Color.White + ) + ) + } + } + }, + floatingActionButton = { + AnimatedContent( + targetState = selected, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "fab_switch" + ) { variant -> + when (variant) { + FabVariant.RADIAL_CLASSIC -> RadialClassicFab() + FabVariant.RADIAL_FULL_ARC -> RadialFullArcFab() + FabVariant.STACK_VERTICAL -> StackVerticalFab() + FabVariant.STACK_MIXED -> StackMixedFab() + FabVariant.STACK_HORIZONTAL -> StackHorizontalFab() + FabVariant.STACK_PUSH -> StackPushFab(onExpandChange = { expandOffset = it }) + FabVariant.MORPH_GRID -> MorphGridFab() + FabVariant.MORPH_DETAIL -> MorphDetailedFab() + } + } + }, + floatingActionButtonPosition = FabPosition.End, + ) { paddingValues -> + Column(modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .offset(y = screenOffsetY, x = screenOffsetX) + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items((1..12).toList()) { i -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(if (i == 0) 72.dp else 56.dp) + .background( + color = if (i % 3 == 0) Color(0xFFF0F0F0) + else if (i % 3 == 1) Color(0xFFE8E8E8) + else Color(0xFFDDDDDD), + shape = RoundedCornerShape(16.dp) + ) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(Color(0xFFCCCCCC), CircleShape) + ) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + modifier = Modifier + .width(120.dp) + .height(10.dp) + .background(Color(0xFFBBBBBB), RoundedCornerShape(4.dp)) + ) + Box( + modifier = Modifier + .width(80.dp) + .height(8.dp) + .background(Color(0xFFCCCCCC), RoundedCornerShape(4.dp)) + ) + } + } + } + } + } + } + } +} + +@Composable +private fun MorphDetailedFab() { + var expanded by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + + BoxWithConstraints { + val screenWidth = maxWidth + val buttonSize = 72.dp + val padding = 16.dp + val targetX = (screenWidth / 2) - (buttonSize / 2) - padding + + MorphFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = emptyList(), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle(color = Purple, size = 72.dp), + itemArrangement = FabMainConfig.ItemArrangement( + morph = Orientation.Morph( + columns = 1, + width = 260.dp, + cardShape = RoundedCornerShape(28.dp) + ) + ), + animation = FabMainConfig.Animation( + itemEnterDelay = 450L, + buttonEnterTransition = FabButtonTransition( + offset = OffsetTransition( + offsetX = -targetX, + offsetY = 0.dp, + spec = tween(durationMillis = 300) + ), + then = FabButtonTransition.SlideTo( + x = -targetX, + y = (-80).dp + ) + FabButtonTransition.ColorTo(Purple.copy(alpha = .4f)) + FabButtonTransition.Scale(scale = 2f) + ), + buttonExitTransition = FabButtonTransition.Rotate( + 0f, + then = FabButtonTransition.Scale( + 1f, + then = FabButtonTransition.SlideTo() + ) + FabButtonTransition( + offset = OffsetTransition( + offsetX = -targetX, + offsetY = 0.dp, + spec = tween(durationMillis = 300) + ) + ) + FabButtonTransition.ColorTo(Purple) + ) + ) + ), + card = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier + .offset(x = 16.dp) + .width(IntrinsicSize.Min) + .background(Purple.copy(alpha = 0.8f), RoundedCornerShape(16.dp)) + .padding(horizontal = 48.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(64.dp) + .background( + Color.White.copy(alpha = 0.6f), RoundedCornerShape(16.dp) + ).clickable( + interactionSource = interactionSource, + indication = null, + onClick = { expanded = !expanded } + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Outlined.AccountBox, + null, + modifier = Modifier.size(32.dp), + tint = Purple + ) + } + Text( + "User Profile", + Modifier.padding(top = 12.dp), + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = Color.White + ) + Text( + "System Administrator", + color = Color.White, + fontSize = 12.sp + ) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally) + ) { + listOf(Icons.Outlined.Edit, Icons.Outlined.Share, Icons.Outlined.Delete).forEach { icon -> + Box( + Modifier.size(44.dp) + .background(Color(0xFFF0F0F0), RoundedCornerShape(10.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + icon, + null, + tint = Purple, + modifier = Modifier.size(20.dp) + ) + } + } + } + } + } + }, + modifier = Modifier + ) + } +} + +@Composable +private fun StackHorizontalFab() { + var expanded by remember { mutableStateOf(false) } + + StackFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = listOf( + FabItem(icon = Icons.Outlined.Share, onClick = { expanded = false }), + FabItem(icon = Icons.Outlined.Favorite, onClick = { expanded = false }), + FabItem(icon = Icons.Outlined.Email, onClick = { expanded = false }) + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle(color = Navy, size = 64.dp), + itemArrangement = FabMainConfig.ItemArrangement(stack = Orientation.Stack(spacedBy = 12.dp)), + animation = FabMainConfig.Animation( + itemEnterDelay = 300L, + itemEnterTransition = FabItemTransition.Slide() + FabItemTransition.Fade(), + itemExitTransition = FabItemTransition.Slide( + dampingRatio = Spring.DampingRatioNoBouncy + ) + FabItemTransition.Fade(100), + buttonEnterTransition = FabButtonTransition.Scale( + scale = 1.1f, + stiffness = Spring.StiffnessHigh, + dampingRatio = Spring.DampingRatioHighBouncy, + then = FabButtonTransition.Scale( + scale = 0.9f, + ) + FabButtonTransition.Rotate(45f) + FabButtonTransition.SlideTo(y = (-48).dp) + ), + buttonExitTransition = FabButtonTransition.Scale( + 1f, + stiffness = Spring.StiffnessHigh, + dampingRatio = Spring.DampingRatioHighBouncy, + then = FabButtonTransition.SlideTo(y = 0.dp) + FabButtonTransition.Rotate(0f) + ) + ) + ) + ) +} + +@Composable +private fun RadialClassicFab() { + var expanded by remember { mutableStateOf(false) } + + RadialFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = listOf( + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Home, + buttonStyle = FabItem.ButtonStyle(color = Orange, size = 56.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.MailOutline, + buttonStyle = FabItem.ButtonStyle(color = Teal, size = 56.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Place, + buttonStyle = FabItem.ButtonStyle(color = Green, size = 56.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Delete, + buttonStyle = FabItem.ButtonStyle(color = Red, size = 56.dp) + ) + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle(color = Orange, size = 72.dp), + itemArrangement = FabMainConfig.ItemArrangement( + radial = Orientation.Radial( + arc = Orientation.Radial.Arc.END, + radius = 150.dp + ) + ), + animation = FabMainConfig.Animation( + itemEnterTransition = FabItemTransition.Slide() + FabItemTransition.Fade(), + itemExitTransition = FabItemTransition.Slide( + dampingRatio = Spring.DampingRatioNoBouncy + ) + FabItemTransition.Fade(), + itemEnterOrder = FabMainConfig.StaggerOrder.FIFO, + itemExitOrder = FabMainConfig.StaggerOrder.FILO, + buttonEnterTransition = FabButtonTransition.Rotate(45f) + FabButtonTransition.Scale(0.85f), + buttonExitTransition = FabButtonTransition.Rotate(0f) + FabButtonTransition.Scale(1f) + ) + ) + ) +} + +@Composable +private fun RadialFullArcFab() { + var expanded by remember { mutableStateOf(false) } + + BoxWithConstraints { + val screenWidth = maxWidth + val buttonSize = 72.dp + val padding = 16.dp + val targetX = (screenWidth / 2) - (buttonSize / 2) - padding + + RadialFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = listOf( + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Edit, + buttonStyle = FabItem.ButtonStyle(color = Purple, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Share, + buttonStyle = FabItem.ButtonStyle(color = Pink, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Favorite, + buttonStyle = FabItem.ButtonStyle(color = Red, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.AccountBox, + buttonStyle = FabItem.ButtonStyle(color = Teal, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Delete, + buttonStyle = FabItem.ButtonStyle(color = Gold, size = 52.dp) + ) + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle( + color = Navy, + size = buttonSize, + shape = RoundedCornerShape(20.dp) + ), + itemArrangement = FabMainConfig.ItemArrangement( + radial = Orientation.Radial( + arc = Orientation.Radial.Arc.CENTER, + radius = 110.dp + ) + ), + animation = FabMainConfig.Animation( + itemEnterDelay = 1000L, + itemEnterTransition = FabItemTransition.Scale() + FabItemTransition.Fade() + FabItemTransition.Rotate(-360f), + itemExitTransition = FabItemTransition.Scale() + FabItemTransition.Fade() + FabItemTransition.Rotate(0f), + itemEnterOrder = FabMainConfig.StaggerOrder.ALL, + itemExitOrder = FabMainConfig.StaggerOrder.ALL, + buttonEnterTransition = FabButtonTransition.SlideTo( + x = -targetX, + then = FabButtonTransition.Rotate(135f) + + FabButtonTransition.Scale(0.9f) + FabButtonTransition.SlideTo(x = -targetX, y = (-8).dp) + + FabButtonTransition.ColorTo(Color.Gray) + ), + buttonExitTransition = FabButtonTransition.SlideTo( + y = 0.dp, + x = -targetX, + then = FabButtonTransition.SlideTo() + FabButtonTransition.Rotate(0f) + FabButtonTransition.Scale(1f) + ) + FabButtonTransition.ColorTo(Navy) + ) + ) + ) + } +} + +@Composable +private fun StackVerticalFab() { + var expanded by remember { mutableStateOf(false) } + + StackFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = listOf( + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.AccountBox, + buttonStyle = FabItem.ButtonStyle(color = Teal, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Edit, + buttonStyle = FabItem.ButtonStyle(color = Purple, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Share, + buttonStyle = FabItem.ButtonStyle(color = Green, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Delete, + buttonStyle = FabItem.ButtonStyle(color = Red, size = 52.dp) + ) + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle(color = Orange, size = 72.dp), + itemArrangement = FabMainConfig.ItemArrangement( + stack = Orientation.Stack(spacedBy = 16.dp) + ), + animation = FabMainConfig.Animation( + itemEnterTransition = FabItemTransition.SlideAndFade() + FabItemTransition.Scale(), + itemExitTransition = FabItemTransition.Slide( + dampingRatio = Spring.DampingRatioNoBouncy + ) + FabItemTransition.Fade(), + itemEnterOrder = FabMainConfig.StaggerOrder.FIFO, + itemExitOrder = FabMainConfig.StaggerOrder.FILO, + buttonEnterTransition = FabButtonTransition.Rotate(45f), + buttonExitTransition = FabButtonTransition.Rotate(0f) + ) + ) + ) +} + +@Composable +private fun StackMixedFab() { + var expanded by remember { mutableStateOf(false) } + + StackFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = listOf( + StackFabItem(direction = StackDirection.TOP) { + Box( + modifier = Modifier + .size(56.dp) + .background(Purple, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("↑1", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + StackFabItem(direction = StackDirection.TOP) { + Box( + modifier = Modifier + .size(56.dp) + .background(Pink, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("↑2", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + StackFabItem(direction = StackDirection.START) { + Box( + modifier = Modifier + .size(56.dp) + .background(Teal, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("←1", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + StackFabItem(direction = StackDirection.START) { + Box( + modifier = Modifier + .size(56.dp) + .background(Green, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("←2", color = Color.White, fontWeight = FontWeight.Bold) + } + } + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle( + color = Navy, + size = 68.dp, + shape = RoundedCornerShape(20.dp) + ), + itemArrangement = FabMainConfig.ItemArrangement( + stack = Orientation.Stack(spacedBy = 14.dp) + ), + animation = FabMainConfig.Animation( + itemEnterTransition = FabItemTransition.Slide() + FabItemTransition.Scale() + FabItemTransition.Fade(), + itemExitTransition = FabItemTransition.Slide( + dampingRatio = Spring.DampingRatioNoBouncy + ) + FabItemTransition.Fade(), + itemEnterOrder = FabMainConfig.StaggerOrder.FIFO, + itemExitOrder = FabMainConfig.StaggerOrder.FILO, + buttonEnterTransition = FabButtonTransition.Rotate(90f) + FabButtonTransition.Scale(0.8f), + buttonExitTransition = FabButtonTransition.Rotate(0f) + FabButtonTransition.Scale(1f) + ) + ) + ) +} + +@Composable +private fun StackPushFab(onExpandChange: (StackExpandOffset) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + + StackFloatingActionButton( + expanded = expanded, + onClick = { + expanded = !expanded + if (!expanded) { + focusManager.clearFocus() + } + }, + onExpandChange = onExpandChange, + items = listOf( + StackFabItem(direction = StackDirection.TOP) { + Box( + modifier = Modifier + .size(56.dp) + .background(Purple, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("↑1", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + StackFabItem(direction = StackDirection.TOP) { + Box( + modifier = Modifier + .size(56.dp) + .background(Purple, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("↑2", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + StackFabItem(direction = StackDirection.TOP) { + Box( + modifier = Modifier + .size(56.dp) + .background(Purple, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("↑3", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + StackFabItem(direction = StackDirection.START) { + var text by remember { mutableStateOf("") } + + Box( + modifier = Modifier.width(330.dp) + ) { + TextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp), + placeholder = { + Text(text = "Hinted search text", color = Color.Gray) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = Color.Black + ) + }, + shape = RoundedCornerShape(28.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color(0xFFF3F3F9), + unfocusedContainerColor = Color(0xFFF3F3F9), + disabledContainerColor = Color(0xFFF3F3F9), + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + singleLine = true + ) + } + } + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle( + color = Orange, + size = 64.dp + ), + itemArrangement = FabMainConfig.ItemArrangement( + stack = Orientation.Stack(spacedBy = 14.dp) + ), + animation = FabMainConfig.Animation( + itemEnterDelay = 300L, + itemEnterTransition = FabItemTransition.Slide() + FabItemTransition.Scale() + FabItemTransition.Fade(), + itemExitTransition = FabItemTransition.Slide() + FabItemTransition.Fade() + FabItemTransition.Scale(), + itemEnterOrder = FabMainConfig.StaggerOrder.FIFO, + itemExitOrder = FabMainConfig.StaggerOrder.FILO, + buttonEnterTransition = FabButtonTransition.Rotate(45f) + FabButtonTransition.Scale( + 0.9f, + then = FabButtonTransition.SlideTo(y = (-8).dp) + ), + buttonExitTransition = FabButtonTransition.Rotate(0f) + FabButtonTransition.Scale( + 1f, + then = FabButtonTransition.SlideTo(y = 0.dp) + ) + ) + ) + ) +} + +@Composable +private fun MorphGridFab() { + var expanded by remember { mutableStateOf(false) } + val cardWidth = 240.dp + val buttonSize = 72.dp + val targetX = (cardWidth / 2) - (buttonSize / 2) + + MorphFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = listOf( + MorphFabItem { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + modifier = Modifier + .size(96.dp) + .background(Orange, RoundedCornerShape(16.dp)) + ) { + Text("🏠", fontSize = 24.sp) + Text("Home", color = Color.White, fontSize = 11.sp) + } + }, + MorphFabItem { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + modifier = Modifier + .size(96.dp) + .background(Teal, RoundedCornerShape(16.dp)) + ) { + Text("📷", fontSize = 24.sp) + Text("Camera", color = Color.White, fontSize = 11.sp) + } + }, + MorphFabItem { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + modifier = Modifier + .size(96.dp) + .background(Purple, RoundedCornerShape(16.dp)) + ) { + Text("✏️", fontSize = 24.sp) + Text("Edit", color = Color.White, fontSize = 11.sp) + } + }, + MorphFabItem { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + modifier = Modifier + .size(96.dp) + .background(Green, RoundedCornerShape(16.dp)) + ) { + Text("🗺️", fontSize = 24.sp) + Text("Maps", color = Color.White, fontSize = 11.sp) + } + } + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle(color = Orange, size = 72.dp), + itemArrangement = FabMainConfig.ItemArrangement( + morph = Orientation.Morph( + columns = 2, + spacedBy = 12.dp, + width = 240.dp, + cardShape = RoundedCornerShape(24.dp) + ) + ), + animation = FabMainConfig.Animation( + itemEnterTransition = FabItemTransition.Scale() + FabItemTransition.Fade(), + itemExitTransition = FabItemTransition.Scale() + FabItemTransition.Fade(), + itemEnterOrder = FabMainConfig.StaggerOrder.FIFO, + itemEnterDelay = 300L, + buttonEnterTransition = FabButtonTransition.SlideTo( + x = -targetX, + y = (-80).dp, + then = FabButtonTransition(scale = ScaleTransition(3f, tween())) + ), + buttonExitTransition = FabButtonTransition.Rotate( + 0f, + then = FabButtonTransition.Scale( + 1f, + then = FabButtonTransition.SlideTo() + ) + ) + ) + ) + ) +} diff --git a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/LineGraph.kt b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/LineGraph.kt new file mode 100644 index 0000000..cf9e020 --- /dev/null +++ b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/LineGraph.kt @@ -0,0 +1,1063 @@ +package com.developerstring.jetco_kmp.preview + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.developerstring.jetco_kmp.charts.linegraph.LineGraph +import com.developerstring.jetco_kmp.charts.linegraph.MultiLineGraph +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphAnimationConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphAreaFillConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphDefaults +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphGridLineStyle +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphLineConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphPointConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphYAxisConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphLiveUpdateConfig +import com.developerstring.jetco_kmp.charts.linegraph.model.LineGraphSeries +import com.developerstring.jetco_kmp.charts.candlestickchart.CandlestickChart +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickDefaults +import com.developerstring.jetco_kmp.charts.candlestickchart.model.CandleChange +import com.developerstring.jetco_kmp.charts.candlestickchart.model.CandlestickEntry +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.coroutines.delay +import kotlin.random.Random + +@Composable +fun LineGraphScreen() { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F7FA)) + .verticalScroll(rememberScrollState()) + .padding(vertical = 60.dp, horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + // 1. Modern Gradient Chart - Vibrant and smooth + GraphCard( + title = "Revenue Growth", + subtitle = "Quarterly performance" + ) { + ModernGradientChart() + } + + // 2. Minimal Clean Chart - Sharp and professional + GraphCard( + title = "Website Traffic", + subtitle = "Daily unique visitors" + ) { + MinimalCleanChart() + } + + // 3. Financial Dashboard Chart - Professional with grid + GraphCard( + title = "Stock Performance", + subtitle = "12-month trend" + ) { + FinancialDashboardChart() + } + + // 4. Dark Theme Chart - Modern dark mode + GraphCard( + title = "Server Performance", + subtitle = "Response time (ms)", + isDark = true + ) { + DarkThemeChart() + } + + // 5. Multi-Line Comparison - Multiple series + GraphCard( + title = "Product Comparison", + subtitle = "Sales across categories" + ) { + MultiLineComparisonChart() + } + + // 6. Performance Metrics - Percentage growth + GraphCard( + title = "Conversion Rate", + subtitle = "Monthly optimization" + ) { + PerformanceMetricsChart() + } + + // 7. Animated Growth Chart - Bold and dynamic + GraphCard( + title = "User Acquisition", + subtitle = "Exponential growth" + ) { + AnimatedGrowthChart() + } + + // 8. Custom Star Markers - Creative point markers + GraphCard( + title = "Premium Features Usage", + subtitle = "With custom star markers" + ) { + CustomStarMarkersChart() + } + + // 9. Custom Popup - Rich tooltip design + GraphCard( + title = "Daily Revenue", + subtitle = "With enhanced popup" + ) { + CustomPopupChart() + } + + // 10. Live Update Chart - Real-time data with blinking point + GraphCard( + title = "Live Temperature Monitor", + subtitle = "Real-time updates with animation" + ) { + LiveUpdateChart() + } + + // 11. Candlestick Chart - Stock market visualization + GraphCard( + title = "Stock Market Analysis", + subtitle = "OHLC chart with volume" + ) { + CandlestickChartExample() + } + } +} + +@Composable +private fun GraphCard( + title: String, + subtitle: String, + isDark: Boolean = false, + content: @Composable () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = if (isDark) Color(0xFF1E1E2E) else Color.White + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text( + text = title, + style = TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = if (isDark) Color.White else Color(0xFF1E1E2E) + ) + ) + Text( + text = subtitle, + style = TextStyle( + fontSize = 14.sp, + color = if (isDark) Color(0xFFB0B0C0) else Color(0xFF6B7280) + ), + modifier = Modifier.padding(top = 4.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + content() + } + } +} + +// 1. Modern Gradient Chart - Vibrant purple-pink gradient +@Composable +private fun ModernGradientChart() { + val vibrantPurple = Color(0xFF8B5CF6) + + LineGraph( + chartData = mapOf( + "Jan" to 45f, + "Feb" to 52f, + "Mar" to 48f, + "Apr" to 65f, + "May" to 71f, + "Jun" to 68f, + "Jul" to 85f, + "Aug" to 92f + ), + chartHeight = 220.dp, + lineConfig = LineGraphLineConfig( + lineColor = vibrantPurple, + lineWidth = 3.dp, + strokeCap = StrokeCap.Round, + smoothCurve = true, + curvature = 0f + ), + areaFillConfig = LineGraphAreaFillConfig( + enabled = true, + brush = Brush.verticalGradient( + listOf( + vibrantPurple.copy(alpha = 0.5f), + Color(0xFFEC4899).copy(alpha = 0.2f), + Color.Transparent + ) + ) + ), + pointConfig = LineGraphPointConfig( + enabled = true, + radius = 5.dp, + color = Color.White, + borderColor = vibrantPurple, + borderWidth = 3.dp + ), + yAxisConfig = LineGraphDefaults.yAxisConfig( + axisScaleCount = 5, + textStyle = TextStyle(fontSize = 12.sp, color = Color(0xFF6B7280)) + ), + xAxisConfig = LineGraphDefaults.xAxisConfig( + textStyle = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Medium) + ), + animationConfig = LineGraphAnimationConfig( + enabled = true, + durationMillis = 1200, + delayMillis = 100 + ), + enableGridLines = true, + gridLineStyle = LineGraphDefaults.gridLineStyle( + color = Color(0xFFE5E7EB), + strokeWidth = 1.dp + ), + horizontalDrawPadding = 0.dp + ) +} + +// 2. Minimal Clean Chart - Simple and sharp +@Composable +private fun MinimalCleanChart() { + val cleanBlue = Color(0xFF3B82F6) + + LineGraph( + chartData = mapOf( + "Mon" to 1200f, + "Tue" to 1450f, + "Wed" to 1380f, + "Thu" to 1620f, + "Fri" to 1890f, + "Sat" to 2100f, + "Sun" to 1750f + ), + chartHeight = 200.dp, + lineConfig = LineGraphLineConfig( + lineColor = cleanBlue, + lineWidth = 2.dp, + strokeCap = StrokeCap.Round, + smoothCurve = false, + curvature = 0.5f + ), + areaFillConfig = LineGraphAreaFillConfig( + enabled = false, + brush = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)) + ), + pointConfig = LineGraphPointConfig( + enabled = true, + radius = 4.dp, + color = cleanBlue, + borderColor = Color.White, + borderWidth = 2.dp + ), + yAxisConfig = LineGraphDefaults.yAxisConfig( + axisScaleCount = 4, + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF9CA3AF)) + ), + xAxisConfig = LineGraphDefaults.xAxisConfig( + isAxisLineEnabled = true, + axisLineColor = Color(0xFFE5E7EB), + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)) + ), + enableGridLines = false, + animationConfig = LineGraphAnimationConfig( + enabled = true, + durationMillis = 800, + delayMillis = 0 + ) + ) +} + +// 3. Financial Dashboard Chart - Professional with dollar signs +@Composable +private fun FinancialDashboardChart() { + val financialGreen = Color(0xFF10B981) + + LineGraph( + chartData = mapOf( + "Jan" to 45000f, + "Feb" to 48000f, + "Mar" to 52000f, + "Apr" to 49000f, + "May" to 58000f, + "Jun" to 63000f, + "Jul" to 67000f, + "Aug" to 71000f, + "Sep" to 76000f, + "Oct" to 82000f, + "Nov" to 88000f, + "Dec" to 95000f + ), + chartHeight = 240.dp, + lineConfig = LineGraphLineConfig( + lineColor = financialGreen, + lineWidth = 2.5.dp, + strokeCap = StrokeCap.Round, + smoothCurve = true, + curvature = 0.5f + ), + areaFillConfig = LineGraphAreaFillConfig( + enabled = true, + brush = Brush.verticalGradient( + listOf( + financialGreen.copy(alpha = 0.3f), + Color.Transparent + ) + ) + ), + pointConfig = LineGraphPointConfig( + enabled = true, + radius = 3.dp, + color = Color.White, + borderColor = financialGreen, + borderWidth = 2.dp + ), + yAxisConfig = LineGraphYAxisConfig( + isAxisScaleEnabled = true, + isAxisLineEnabled = false, + axisLineWidth = 0.dp, + axisLineShape = RoundedCornerShape(0.dp), + axisLineColor = Color.Transparent, + axisScaleCount = 6, + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)), + textPrefix = "$", + textPostfix = "k" + ), + xAxisConfig = LineGraphDefaults.xAxisConfig( + textStyle = TextStyle(fontSize = 10.sp, color = Color(0xFF9CA3AF)) + ), + enableGridLines = true, + gridLineStyle = LineGraphGridLineStyle( + color = Color(0xFFF3F4F6), + strokeWidth = 1.dp, + dashLength = 8.dp, + gapLength = 4.dp, + totalGridLines = 6, + dashCap = StrokeCap.Round + ), + scrollEnabled = true, + minPointSpacing = 60.dp, + autoShrinkXAxisLabels = true, + animationConfig = LineGraphAnimationConfig( + enabled = true, + durationMillis = 1500, + delayMillis = 200 + ) + ) +} + +// 4. Dark Theme Chart - Modern dark mode +@Composable +private fun DarkThemeChart() { + val neonCyan = Color(0xFF06B6D4) + + LineGraph( + chartData = mapOf( + "00:00" to 45f, + "04:00" to 32f, + "08:00" to 78f, + "12:00" to 95f, + "16:00" to 88f, + "20:00" to 62f + ), + chartHeight = 200.dp, + lineConfig = LineGraphLineConfig( + lineColor = neonCyan, + lineWidth = 3.dp, + strokeCap = StrokeCap.Round, + smoothCurve = true, + curvature = 0.6f + ), + areaFillConfig = LineGraphAreaFillConfig( + enabled = true, + brush = Brush.verticalGradient( + listOf( + neonCyan.copy(alpha = 0.4f), + Color(0xFF8B5CF6).copy(alpha = 0.1f), + Color.Transparent + ) + ) + ), + pointConfig = LineGraphPointConfig( + enabled = true, + radius = 6.dp, + color = Color(0xFF1E1E2E), + borderColor = neonCyan, + borderWidth = 2.5.dp + ), + yAxisConfig = LineGraphDefaults.yAxisConfig( + axisScaleCount = 4, + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF94A3B8)), + textPostfix = "ms" + ), + xAxisConfig = LineGraphDefaults.xAxisConfig( + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF64748B)) + ), + enableGridLines = true, + gridLineStyle = LineGraphDefaults.gridLineStyle( + color = Color(0xFF2D3748), + strokeWidth = 1.dp + ), + animationConfig = LineGraphAnimationConfig( + enabled = true, + durationMillis = 1000, + delayMillis = 150 + ), + horizontalDrawPadding = 12.dp + ) +} + +// 5. Multi-Line Comparison Chart +@Composable +private fun MultiLineComparisonChart() { + MultiLineGraph( + labels = listOf("Q1", "Q2", "Q3", "Q4"), + seriesList = listOf( + LineGraphSeries( + name = "Electronics", + data = listOf(45f, 62f, 58f, 75f), + color = Color(0xFF3B82F6) + ), + LineGraphSeries( + name = "Clothing", + data = listOf(38f, 45f, 52f, 48f), + color = Color(0xFFEF4444) + ), + LineGraphSeries( + name = "Home & Garden", + data = listOf(52f, 48f, 65f, 72f), + color = Color(0xFF10B981) + ) + ), + chartHeight = 220.dp, + lineConfig = LineGraphLineConfig( + lineColor = Color.Blue, + lineWidth = 2.5.dp, + strokeCap = StrokeCap.Round, + smoothCurve = true, + curvature = 0.5f + ), + pointConfig = LineGraphPointConfig( + enabled = true, + radius = 4.dp, + color = Color.White, + borderColor = Color.Blue, + borderWidth = 2.dp + ), + enableAreaFill = true, + yAxisConfig = LineGraphDefaults.yAxisConfig( + axisScaleCount = 5, + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)), + textPostfix = "k" + ), + xAxisConfig = LineGraphDefaults.xAxisConfig( + textStyle = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Medium) + ), + enableGridLines = true, + gridLineStyle = LineGraphDefaults.gridLineStyle( + color = Color(0xFFE5E7EB) + ), + enableLegend = true, + animationConfig = LineGraphAnimationConfig( + enabled = true, + durationMillis = 1300, + delayMillis = 100 + ), + horizontalDrawPadding = 16.dp + ) +} + +// 6. Performance Metrics Chart - Percentage values +@Composable +private fun PerformanceMetricsChart() { + val performanceOrange = Color(0xFFF97316) + + LineGraph( + chartData = mapOf( + "Jan" to 2.3f, + "Feb" to 2.8f, + "Mar" to 3.1f, + "Apr" to 3.5f, + "May" to 4.2f, + "Jun" to 4.8f, + "Jul" to 5.5f, + "Aug" to 6.2f + ), + chartHeight = 210.dp, + lineConfig = LineGraphLineConfig( + lineColor = performanceOrange, + lineWidth = 3.5.dp, + strokeCap = StrokeCap.Round, + smoothCurve = true, + curvature = 0.65f + ), + areaFillConfig = LineGraphAreaFillConfig( + enabled = true, + brush = Brush.verticalGradient( + listOf( + performanceOrange.copy(alpha = 0.45f), + Color(0xFFFBBF24).copy(alpha = 0.15f), + Color.Transparent + ) + ) + ), + pointConfig = LineGraphPointConfig( + enabled = true, + radius = 5.dp, + color = Color.White, + borderColor = performanceOrange, + borderWidth = 3.dp + ), + yAxisConfig = LineGraphYAxisConfig( + isAxisScaleEnabled = true, + isAxisLineEnabled = false, + axisLineWidth = 0.dp, + axisLineShape = RoundedCornerShape(0.dp), + axisLineColor = Color.Transparent, + axisScaleCount = 4, + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)), + textPrefix = "", + textPostfix = "%" + ), + xAxisConfig = LineGraphDefaults.xAxisConfig( + isAxisLineEnabled = true, + axisLineColor = Color(0xFFE5E7EB), + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)) + ), + enableGridLines = true, + gridLineStyle = LineGraphDefaults.gridLineStyle( + color = Color(0xFFF3F4F6) + ), + animationConfig = LineGraphAnimationConfig( + enabled = true, + durationMillis = 1400, + delayMillis = 100 + ), + horizontalDrawPadding = 10.dp + ) +} + +// 7. Animated Growth Chart - Bold and dynamic +@Composable +private fun AnimatedGrowthChart() { + val boldMagenta = Color(0xFFDB2777) + + LineGraph( + chartData = mapOf( + "W1" to 100f, + "W2" to 150f, + "W3" to 280f, + "W4" to 420f, + "W5" to 680f, + "W6" to 1050f, + "W7" to 1580f, + "W8" to 2200f + ), + chartHeight = 230.dp, + lineConfig = LineGraphLineConfig( + lineColor = boldMagenta, + lineWidth = 4.dp, + strokeCap = StrokeCap.Round, + smoothCurve = true, + curvature = 0.1f + ), + areaFillConfig = LineGraphAreaFillConfig( + enabled = true, + brush = Brush.verticalGradient( + listOf( + boldMagenta.copy(alpha = 0.6f), + Color(0xFF9333EA).copy(alpha = 0.3f), + Color(0xFF3B82F6).copy(alpha = 0.05f), + Color.Transparent + ) + ) + ), + pointConfig = LineGraphPointConfig( + enabled = true, + radius = 6.dp, + color = Color.White, + borderColor = boldMagenta, + borderWidth = 3.dp + ), + yAxisConfig = LineGraphDefaults.yAxisConfig( + axisScaleCount = 5, + textStyle = TextStyle( + fontSize = 11.sp, + color = Color(0xFF6B7280), + fontWeight = FontWeight.Medium + ) + ), + xAxisConfig = LineGraphDefaults.xAxisConfig( + textStyle = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF374151) + ) + ), + enableGridLines = true, + gridLineStyle = LineGraphDefaults.gridLineStyle( + color = Color(0xFFE5E7EB), + strokeWidth = 1.5.dp + ), + animationConfig = LineGraphAnimationConfig( + enabled = true, + durationMillis = 2000, + delayMillis = 300 + ), + horizontalDrawPadding = 12.dp + ) +} + +// 8. Custom Star Markers Chart - Creative custom point markers +@Composable +private fun CustomStarMarkersChart() { + val starGold = Color(0xFFFBBF24) + + LineGraph( + chartData = mapOf( + "Week 1" to 3.5f, + "Week 2" to 4.2f, + "Week 3" to 3.8f, + "Week 4" to 4.9f, + "Week 5" to 5.3f + ), + chartHeight = 220.dp, + lineConfig = LineGraphLineConfig( + lineColor = starGold, + lineWidth = 3.dp, + strokeCap = StrokeCap.Round, + smoothCurve = true, + curvature = 0.6f + ), + areaFillConfig = LineGraphAreaFillConfig( + enabled = true, + brush = Brush.verticalGradient( + listOf( + starGold.copy(alpha = 0.3f), + Color.Transparent + ) + ) + ), + pointConfig = LineGraphPointConfig( + enabled = true, + radius = 0.dp, + color = Color.Transparent, + borderColor = Color.Transparent, + borderWidth = 0.dp + ), + yAxisConfig = LineGraphDefaults.yAxisConfig( + axisScaleCount = 5, + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)) + ), + xAxisConfig = LineGraphDefaults.xAxisConfig( + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)) + ), + enableGridLines = true, + gridLineStyle = LineGraphDefaults.gridLineStyle( + color = Color(0xFFE5E7EB) + ), + animationConfig = LineGraphAnimationConfig( + enabled = true, + durationMillis = 1200, + delayMillis = 100 + ), + horizontalDrawPadding = 12.dp, + customPointMarker = { label, value, offset -> + // Custom star-shaped marker + Box( + modifier = Modifier + .background( + color = starGold, + shape = RoundedCornerShape(50) + ) + .padding(8.dp) + ) { + Text( + text = "★", + style = TextStyle( + fontSize = 16.sp, + color = Color.White, + fontWeight = FontWeight.Bold + ) + ) + } + } + ) +} + +// 9. Custom Popup Chart - Enhanced rich tooltip +@Composable +private fun CustomPopupChart() { + val richBlue = Color(0xFF2563EB) + + LineGraph( + chartData = mapOf( + "Mon" to 1250f, + "Tue" to 1580f, + "Wed" to 1420f, + "Thu" to 1890f, + "Fri" to 2100f, + "Sat" to 2350f, + "Sun" to 1980f + ), + chartHeight = 220.dp, + lineConfig = LineGraphLineConfig( + lineColor = richBlue, + lineWidth = 3.dp, + strokeCap = StrokeCap.Round, + smoothCurve = true, + curvature = 0.65f + ), + areaFillConfig = LineGraphAreaFillConfig( + enabled = true, + brush = Brush.verticalGradient( + listOf( + richBlue.copy(alpha = 0.4f), + Color(0xFF7C3AED).copy(alpha = 0.1f), + Color.Transparent + ) + ) + ), + pointConfig = LineGraphPointConfig( + enabled = true, + radius = 5.dp, + color = Color.White, + borderColor = richBlue, + borderWidth = 2.5.dp + ), + yAxisConfig = LineGraphDefaults.yAxisConfig( + axisScaleCount = 5, + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)), + textPrefix = "$" + ), + xAxisConfig = LineGraphDefaults.xAxisConfig( + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)) + ), + enableGridLines = true, + gridLineStyle = LineGraphDefaults.gridLineStyle( + color = Color(0xFFE5E7EB) + ), + animationConfig = LineGraphAnimationConfig( + enabled = true, + durationMillis = 1300, + delayMillis = 100 + ), + horizontalDrawPadding = 12.dp, + customPopup = { label, value, onDismiss -> + // Custom rich popup + androidx.compose.ui.window.Popup( + alignment = androidx.compose.ui.Alignment.Center, + onDismissRequest = onDismiss + ) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = richBlue + ), + elevation = CardDefaults.cardElevation(8.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = label, + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "$${formatTo2Decimals(value)}", + style = TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.ExtraBold, + color = Color(0xFFFBBF24) + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Daily Revenue", + style = TextStyle( + fontSize = 10.sp, + color = Color.White.copy(alpha = 0.8f) + ) + ) + } + } + } + } + ) +} + +// 10. Live Update Chart - Real-time data with blinking last point +@Composable +private fun LiveUpdateChart() { + var chartData by remember { + mutableStateOf( + mapOf( + "10:00" to 22.5f, + "10:05" to 23.2f, + "10:10" to 22.8f, + "10:15" to 24.1f, + "10:20" to 25.3f + ) + ) + } + + // Simulate live updates + LaunchedEffect(Unit) { + val timeLabels = listOf("10:00", "10:05", "10:10", "10:15", "10:20", "10:25", "10:30", "10:35") + var currentIndex = 5 + + while (currentIndex < timeLabels.size) { + delay(2000) // Update every 2 seconds + + val newValue = Random.nextFloat() * (30f - 20f) + 20f + val entries = chartData.toMutableMap() + + // Add new entry + entries[timeLabels[currentIndex]] = newValue + + // Keep only last 5 entries + if (entries.size > 5) { + val sortedEntries = entries.entries.sortedBy { timeLabels.indexOf(it.key) } + chartData = sortedEntries.takeLast(5).associate { it.key to it.value } + } else { + chartData = entries + } + + currentIndex++ + if (currentIndex >= timeLabels.size) currentIndex = 5 // Loop back for demo + } + } + + val liveOrange = Color(0xFFFF6B35) + + LineGraph( + chartData = chartData, + chartHeight = 220.dp, + lineConfig = LineGraphLineConfig( + lineColor = liveOrange, + lineWidth = 3.dp, + strokeCap = StrokeCap.Round, + smoothCurve = true, + curvature = 0.3f + ), + areaFillConfig = LineGraphAreaFillConfig( + enabled = true, + brush = Brush.verticalGradient( + listOf( + liveOrange.copy(alpha = 0.3f), + Color.Transparent + ) + ) + ), + pointConfig = LineGraphPointConfig( + enabled = true, + radius = 6.dp, + color = liveOrange, + borderColor = Color.White, + borderWidth = 2.dp + ), + liveUpdateConfig = LineGraphLiveUpdateConfig( + enabled = true, + blinkEnabled = true, + blinkDurationMillis = 1000, + blinkMinRadius = 8f, + blinkMaxRadius = 20f, + blinkColor = liveOrange, + pathTransitionDurationMillis = 800 + ), + xAxisConfig = LineGraphDefaults.xAxisConfig( + isAxisScaleEnabled = true, + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)) + ), + yAxisConfig = LineGraphDefaults.yAxisConfig( + axisScaleCount = 4, + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)) + ), + gridLineStyle = LineGraphGridLineStyle( + color = Color(0xFFF3F4F6), + strokeWidth = 1.dp + ), + animationConfig = LineGraphAnimationConfig( + enabled = true, + durationMillis = 800 + ), + horizontalDrawPadding = 12.dp, + ) +} + +// 11. Candlestick Chart Example - Stock market OHLC chart +@Composable +private fun CandlestickChartExample() { + // Sample stock data + val stockData = listOf( + CandlestickEntry( + label = "Mon", + open = 150.0f, + high = 155.0f, + low = 148.0f, + close = 153f, + volume = 1200000.0f, + change = CandleChange.Bullish + ), + CandlestickEntry( + label = "Tue", + open = 153.0f, + high = 158.0f, + low = 152.0f, + close = 156f, + volume = 1500000.0f, + change = CandleChange.Bullish + ), + CandlestickEntry( + label = "Wed", + open = 156.0f, + high = 157.0f, + low = 151.0f, + close = 152.0f, + volume = 1100000.0f, + change = CandleChange.Bearish + ), + CandlestickEntry( + label = "Thu", + open = 152.0f, + high = 154.0f, + low = 149.0f, + close = 151.0f, + volume = 980000.0f, + change = CandleChange.Bearish + ), + CandlestickEntry( + label = "Fri", + open = 151.0f, + high = 159.0f, + low = 150.0f, + close = 158.0f, + volume = 1800000.0f, + change = CandleChange.Bullish + ), + CandlestickEntry( + label = "Sat", + open = 158.0f, + high = 162.0f, + low = 157.0f, + close = 161.0f, + volume = 1600000.0f, + change = CandleChange.Bullish + ), + CandlestickEntry( + label = "Sun", + open = 161.0f, + high = 163.0f, + low = 158.0f, + close = 159.0f, + volume = 1300000.0f, + change = CandleChange.Bearish + ) + ) + + CandlestickChart( + data = stockData, + modifier = Modifier + .fillMaxWidth(), + candleConfig = CandlestickDefaults.candleConfig( + bullishColor = Color(0xFF10B981), + bearishColor = Color(0xFFEF4444), + hollowBullish = false, + bodyWidthDp = 12.dp, + wickWidthDp = 1.5.dp + ), + volumeConfig = CandlestickDefaults.volumeConfig( + enabled = true, + bullishColor = Color(0xFF10B981).copy(alpha = 0.5f), + bearishColor = Color(0xFFEF4444).copy(alpha = 0.5f), + heightRatio = 0.25f + ), + markerConfig = CandlestickDefaults.markerConfig( + enabled = true, + background = Color(0xFF1F2937), + textStyle = TextStyle( + fontSize = 12.sp, + color = Color.White + ) + ), + crosshairConfig = CandlestickDefaults.crosshairConfig( + enabled = true, + lineColor = Color(0xFF6B7280), + lineWidthDp = 1.dp + ), + yAxisConfig = CandlestickDefaults.yAxisConfig( + isAxisScaleEnabled = true, + axisScaleCount = 5, + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)) + ), + xAxisConfig = CandlestickDefaults.xAxisConfig( + isAxisScaleEnabled = true, + textStyle = TextStyle(fontSize = 11.sp, color = Color(0xFF6B7280)) + ), + gridLineStyle = CandlestickDefaults.gridLineStyle( + color = Color(0xFFF3F4F6), + strokeWidth = 1.dp + ), + animationConfig = CandlestickDefaults.animationConfig( + enabled = true, + durationMillis = 1000 + ) + ) +} + +// ━━━━━━━━━━━━━━━━━━━━━ Helper Functions ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/** + * Formats a Float value to exactly 2 decimal places. + * Pure Kotlin implementation for Multiplatform compatibility. + */ +private fun formatTo2Decimals(value: Float): String { + if (value.isNaN() || value.isInfinite()) return "0.00" + val negative = value < 0f + val absRounded = kotlin.math.round(kotlin.math.abs(value) * 100).toLong() + val intPart = absRounded / 100 + val decPart = (absRounded % 100).toInt() + val prefix = if (negative && (intPart > 0 || decPart > 0)) "-" else "" + return "$prefix$intPart.${decPart.toString().padStart(2, '0')}" +} diff --git a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/StepperCompKMP.kt b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/StepperCompKMP.kt index 21ff195..5076475 100644 --- a/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/StepperCompKMP.kt +++ b/JetCoKMP/composeApp/src/commonMain/kotlin/com/developerstring/jetco_kmp/preview/StepperCompKMP.kt @@ -198,7 +198,8 @@ fun StepperPreview() { completed = Icons.Filled.FavoriteBorder, error = Icons.Filled.Info, active = Icons.Rounded.Notifications - ) + ), + ) } } diff --git a/JetCoKMP/gradle.properties b/JetCoKMP/gradle.properties index b4b11e6..e69de29 100644 --- a/JetCoKMP/gradle.properties +++ b/JetCoKMP/gradle.properties @@ -1,19 +0,0 @@ -#Kotlin -kotlin.code.style=official -kotlin.daemon.jvmargs=-Xmx3072M - -#Gradle -org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 -org.gradle.configuration-cache=true -org.gradle.caching=true - -#Android -android.nonTransitiveRClass=true -android.useAndroidX=true - -signing.keyId= -signing.password= -signing.secretKeyRingFile= - -mavenCentralUsername= -mavenCentralPassword= \ No newline at end of file diff --git a/JetCoKMP/gradle/libs.versions.toml b/JetCoKMP/gradle/libs.versions.toml index e31c3dd..4741677 100644 --- a/JetCoKMP/gradle/libs.versions.toml +++ b/JetCoKMP/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.12.3" +agp = "8.11.2" android-compileSdk = "36" android-minSdk = "24" android-targetSdk = "36" @@ -14,6 +14,7 @@ composeMultiplatform = "1.9.0" junit = "4.13.2" kotlin = "2.2.20" kotlinx-coroutines = "1.10.2" +kotlinx-datetime = "0.6.2" materialIconsExtended = "1.7.3" ui = "1.0.0-beta.8" @@ -31,6 +32,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } ui = { module = "com.developerstring.jetco-kmp:ui", version.ref = "ui" } diff --git a/JetCoKMP/jetco/build.gradle.kts b/JetCoKMP/jetco/build.gradle.kts index e5e4f2f..3b80401 100644 --- a/JetCoKMP/jetco/build.gradle.kts +++ b/JetCoKMP/jetco/build.gradle.kts @@ -71,6 +71,7 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.material.icons.extended) + implementation(libs.kotlinx.datetime) } commonTest.dependencies { implementation(libs.kotlin.test) @@ -134,12 +135,12 @@ mavenPublishing { signAllPublications() - coordinates("com.developerstring.jetco-kmp", "ui", "1.0.0-beta.8") + coordinates("com.developerstring.jetco-kmp", "ui", "1.1.0-beta") pom { name = "JetCo" description = "A versatile Jetpack Compose library for building modern UI for your KMP Apps." - inceptionYear = "2025" + inceptionYear = "2026" url = "https://github.com/developerchunk/jetco/" licenses { license { diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/ColumnBarChart.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/ColumnBarChart.kt index 385b080..3862c75 100644 --- a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/ColumnBarChart.kt +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/ColumnBarChart.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import com.developerstring.jetco_kmp.charts.barchart.components.formatScaleValue import androidx.compose.ui.unit.dp import com.developerstring.jetco_kmp.charts.barchart.components.BarChartPopup import com.developerstring.jetco_kmp.charts.barchart.components.XAxisLabel @@ -181,7 +182,7 @@ fun ColumnBarChart( if (isBarPopupVisible && popUpConfig.enableBarPopUp) { BarChartPopup( popUpConfig = popUpConfig, - text = barPopupText.toString(), + text = formatScaleValue(barPopupText), onDismissRequest = { isBarPopupVisible = false } diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/ExtendedColumnBarChart.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/ExtendedColumnBarChart.kt index 6a1f85d..e2b2583 100644 --- a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/ExtendedColumnBarChart.kt +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/ExtendedColumnBarChart.kt @@ -34,6 +34,7 @@ import com.developerstring.jetco_kmp.charts.barchart.components.BarChartPopup import com.developerstring.jetco_kmp.charts.barchart.components.XAxisLabel import com.developerstring.jetco_kmp.charts.barchart.components.YAxisGridLines import com.developerstring.jetco_kmp.charts.barchart.components.YAxisScale +import com.developerstring.jetco_kmp.charts.barchart.components.formatScaleValue import com.developerstring.jetco_kmp.charts.barchart.config.BarChartConfig import com.developerstring.jetco_kmp.charts.barchart.config.BarChartDefaults import com.developerstring.jetco_kmp.charts.barchart.config.GridLineStyle @@ -147,7 +148,7 @@ fun ExtendedColumnBarChart( modifier = Modifier.height(height = yAxisStepHeight), verticalAlignment = Alignment.Bottom ) { - yAxisScaleLabel((yAxisScaleStep * barScale).toString()) + yAxisScaleLabel(formatScaleValue(yAxisScaleStep * barScale)) } } } @@ -239,12 +240,12 @@ fun ExtendedColumnBarChart( isBarPopupVisible = false }, ) { - barPopUp(barPopupText.toString()) + barPopUp(formatScaleValue(barPopupText)) } } else { BarChartPopup( popUpConfig = popUpConfig, - text = barPopupText.toString(), + text = formatScaleValue(barPopupText), onDismissRequest = { isBarPopupVisible = false } @@ -311,7 +312,7 @@ fun ExtendedColumnBarChart( contentAlignment = Alignment.BottomCenter ) { - barDesign(barItem.value.toString()) + barDesign(formatScaleValue(barItem.value)) } } else { diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/GroupColumnBarChart.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/GroupColumnBarChart.kt index 0943994..9dbd596 100644 --- a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/GroupColumnBarChart.kt +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/GroupColumnBarChart.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import com.developerstring.jetco_kmp.charts.barchart.components.formatScaleValue import androidx.compose.ui.unit.dp import com.developerstring.jetco_kmp.charts.barchart.components.BarChartPopup import com.developerstring.jetco_kmp.charts.barchart.components.XAxisLabel @@ -206,7 +207,7 @@ fun GroupColumnBarChart( if (isBarPopupVisible && popUpConfig.enableBarPopUp) { BarChartPopup( popUpConfig = popUpConfig, - text = barPopupText.toString(), + text = formatScaleValue(barPopupText), onDismissRequest = { isBarPopupVisible = false } diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/components/GridLine.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/components/GridLine.kt index 22e3e0f..70c4a95 100644 --- a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/components/GridLine.kt +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/components/GridLine.kt @@ -28,8 +28,11 @@ fun GridLine( val gapLengthPx = gridLineStyle.gapLength.toPx() val strokeWidthPx = gridLineStyle.strokeWidth.toPx() - // Define the PathEffect to create dashes - val pathEffect = PathEffect.dashPathEffect(floatArrayOf(dashLengthPx, gapLengthPx), 0f) + // Skia's MakeDash requires all intervals > 0; passing 0 returns + // nullptr which crashes on Kotlin/Native ("Can't wrap nullptr"). + val pathEffect = if (dashLengthPx > 0f && gapLengthPx > 0f) { + PathEffect.dashPathEffect(floatArrayOf(dashLengthPx, gapLengthPx), 0f) + } else null // Draw the dashed line drawLine( diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/components/YAxisScale.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/components/YAxisScale.kt index 2dd5f19..57adb16 100644 --- a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/components/YAxisScale.kt +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/components/YAxisScale.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.developerstring.jetco_kmp.charts.barchart.config.YAxisConfig import kotlin.math.abs +import kotlin.math.round /** * A composable function that displays the Y-axis scale labels and an optional Y-axis line for a chart. @@ -45,7 +46,7 @@ fun YAxisScale( verticalAlignment = Alignment.Bottom ) { Text( - text = yAxisConfig.textPrefix + yAxisScaleStep * barScale + yAxisConfig.textPostfix, + text = yAxisConfig.textPrefix + formatScaleValue(yAxisScaleStep * barScale) + yAxisConfig.textPostfix, style = yAxisConfig.textStyle ) } @@ -81,25 +82,37 @@ fun YAxisScale( * @return A formatted string representing the value with appropriate suffixes for readability. */ fun formatScaleValue(value: Float): String { + if (value.isNaN() || value.isInfinite()) return "0" - // Defines the decimal format to display up to two decimal places - - // For values less than 10,000, display the value as is - if (value < 10000) { - return if (value % 1f == 0.0f) value.toInt().toString() else value.toString() + if (abs(value) < 10000) { + return formatBarDecimal(value) } val am: Float - // For values in millions, format the value with an "M" suffix - if (abs(value / 1000000) >= 1) { - am = value / 1000000 - return if (am % 1f == 0.0f) am.toInt().toString() else am.toString() + "M" - } else if (abs(value / 1000) >= 1) { // For values in thousands, format the value with a "K" suffix - am = value / 1000 - return if (am % 1f == 0.0f) am.toInt().toString() else am.toString() + "K" - } else { - // Default case, return the formatted value without suffix - return if (value % 1f == 0.0f) value.toInt().toString() else value.toString() + if (abs(value / 1_000_000) >= 1) { + am = value / 1_000_000 + return formatBarDecimal(am) + "M" + } else if (abs(value / 1_000) >= 1) { + am = value / 1_000 + return formatBarDecimal(am) + "K" } + return formatBarDecimal(value) +} +/** + * NaN-safe decimal formatting using integer arithmetic. + * Produces at most 2 decimal places, removing trailing zeros. + */ +private fun formatBarDecimal(value: Float): String { + if (value.isNaN() || value.isInfinite()) return "0" + val negative = value < 0f + val absRounded = round(abs(value) * 100).toLong() + val intPart = absRounded / 100 + val decPart = (absRounded % 100).toInt() + val prefix = if (negative && (intPart > 0 || decPart > 0)) "-" else "" + return when { + decPart == 0 -> "$prefix$intPart" + decPart % 10 == 0 -> "$prefix$intPart.${decPart / 10}" + else -> "$prefix$intPart.${decPart.toString().padStart(2, '0')}" + } } \ No newline at end of file diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/config/BarChartConfigurations.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/config/BarChartConfigurations.kt index 148a9ce..5673516 100644 --- a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/config/BarChartConfigurations.kt +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/barchart/config/BarChartConfigurations.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.unit.Dp * @property width The width of the bar. * @property shape The shape of the bar (e.g., rounded corners). */ -@Stable data class BarChartConfig( val color: Color, val height: Dp, @@ -33,7 +32,6 @@ data class BarChartConfig( * @property gapBetweenBar The gap between individual bars within a group. * @property gapBetweenGroup The gap between different groups of bars. */ -@Stable data class GroupBarChartConfig( val colors: List, val height: Dp, @@ -53,7 +51,6 @@ data class GroupBarChartConfig( * @property axisLineColor The color of the axis line. * @property textStyle The [TextStyle] of the text for axis labels. */ -@Stable data class XAxisConfig( val isAxisScaleEnabled: Boolean, val isAxisLineEnabled: Boolean, @@ -76,7 +73,6 @@ data class XAxisConfig( * @property textPrefix The prefix to be added before the scale value. * @property textPostfix The postfix to be added after the scale value. */ -@Stable data class YAxisConfig( val isAxisScaleEnabled: Boolean, val isAxisLineEnabled: Boolean, diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/CandlestickChart.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/CandlestickChart.kt new file mode 100644 index 0000000..ac4a57d --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/CandlestickChart.kt @@ -0,0 +1,614 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.candlestickchart + +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.sp +import com.developerstring.jetco_kmp.charts.candlestickchart.components.CandlestickMarkerPopup +import com.developerstring.jetco_kmp.charts.candlestickchart.components.CandlestickYAxisGridLines +import com.developerstring.jetco_kmp.charts.candlestickchart.components.CandlestickYAxisScale +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickAnimationConfig +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickCandleConfig +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickCrosshairConfig +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickDefaults +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickGridLineStyle +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickMarkerConfig +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickVolumeConfig +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickXAxisConfig +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickYAxisConfig +import com.developerstring.jetco_kmp.charts.candlestickchart.model.CandleChange +import com.developerstring.jetco_kmp.charts.candlestickchart.model.CandlestickEntry +import kotlin.math.abs + +/** + * A fully customisable candlestick (OHLC) chart composable for + * Jetpack Compose, inspired by the Vico library. + * + * Renders each data point as a candle with a body (open→close) and + * wicks (high/low). Supports: + * + * - **Bullish / Bearish / Neutral** colour coding + * - **Hollow** bullish candles + * - **Volume bars** below the candles + * - **Crosshair** indicator on selection + * - **OHLC marker tooltip** (default or custom) + * - **Grid lines** and **axis labels** + * - **Entrance animation** (fade-in + vertical stretch) + * - **Horizontal scrolling** for large data sets + * + * ## Quick start + * ```kotlin + * CandlestickChart( + * data = listOf( + * CandlestickEntry("Mon", 100f, 110f, 95f, 105f), + * CandlestickEntry("Tue", 105f, 115f, 100f, 98f), + * ), + * ) + * ``` + * + * ## Full customisation + * ```kotlin + * CandlestickChart( + * modifier = Modifier.fillMaxWidth().padding(16.dp), + * data = entries, + * chartHeight = 300.dp, + * candleConfig = CandlestickDefaults.candleConfig(hollowBullish = true), + * volumeConfig = CandlestickDefaults.volumeConfig(heightRatio = 0.25f), + * crosshairConfig= CandlestickDefaults.crosshairConfig(), + * markerConfig = CandlestickDefaults.markerConfig(), + * yAxisConfig = CandlestickDefaults.yAxisConfig(textPrefix = "$"), + * onCandleClicked = { entry -> Log.d("Chart", entry.toString()) }, + * ) + * ``` + * + * @param modifier Modifier applied to the outermost container. + * @param data Ordered list of [CandlestickEntry] values. The insertion + * order determines the X-axis position. + * @param chartHeight Height of the candle area (excluding volume and axes). + * @param candleConfig Visual configuration for candle bodies and wicks. + * @param volumeConfig Volume-bar overlay configuration. + * @param crosshairConfig Crosshair indicator configuration. + * @param markerConfig OHLC marker tooltip configuration. + * @param yAxisConfig Y-axis labels and line. + * @param xAxisConfig X-axis labels and line. + * @param gridLineStyle Horizontal grid lines. + * @param animationConfig Entrance animation timing. + * @param minValue Optional Y-axis floor. When `null` the global low is used. + * @param maxValue Optional Y-axis ceiling. When `null` the global high is used. + * @param enableGridLines Whether to draw grid lines. + * @param maxTextLengthXAxis Labels longer than this are truncated. + * @param horizontalDrawPadding Space between the Y-axis and the first / last candle. + * @param scrollEnabled Enables horizontal scrolling for large data sets. + * @param minCandleSpacing Minimum horizontal gap between candle centres + * when [scrollEnabled] is `true`. + * @param autoShrinkXAxisLabels Auto-reduce font size for many X labels. + * @param autoShrinkYAxisLabels Auto-reduce font size for many Y labels. + * @param customMarker Optional custom composable for the marker tooltip. + * @param onCandleClicked Callback invoked when a candle is tapped. + * + * @see CandlestickDefaults + * @see CandlestickEntry + */ +@Composable +fun CandlestickChart( + modifier: Modifier = Modifier, + data: List, + chartHeight: Dp = 250.dp, + candleConfig: CandlestickCandleConfig = CandlestickDefaults.candleConfig(), + volumeConfig: CandlestickVolumeConfig = CandlestickDefaults.volumeConfig(), + crosshairConfig: CandlestickCrosshairConfig = CandlestickDefaults.crosshairConfig(), + markerConfig: CandlestickMarkerConfig = CandlestickDefaults.markerConfig(), + yAxisConfig: CandlestickYAxisConfig = CandlestickDefaults.yAxisConfig(), + xAxisConfig: CandlestickXAxisConfig = CandlestickDefaults.xAxisConfig(), + gridLineStyle: CandlestickGridLineStyle = CandlestickDefaults.gridLineStyle(), + animationConfig: CandlestickAnimationConfig = CandlestickDefaults.animationConfig(), + minValue: Float? = null, + maxValue: Float? = null, + enableGridLines: Boolean = true, + maxTextLengthXAxis: Int = 6, + horizontalDrawPadding: Dp = 16.dp, + scrollEnabled: Boolean = false, + minCandleSpacing: Dp = 16.dp, + autoShrinkXAxisLabels: Boolean = false, + autoShrinkYAxisLabels: Boolean = false, + customMarker: (@Composable (entry: CandlestickEntry, onDismiss: () -> Unit) -> Unit)? = null, + onCandleClicked: ((CandlestickEntry) -> Unit)? = null, +) { + if (data.isEmpty()) return + + // ── price range ────────────────────────────────────────────────── + val globalLow = (minValue ?: data.minOf { it.low }).let { if (it.isNaN() || it.isInfinite()) 0f else it } + val globalHigh = (maxValue ?: data.maxOf { it.high }).let { if (it.isNaN() || it.isInfinite()) 1f else it } + // Add 5% padding to the price range to avoid candles touching edges + val priceRange = (globalHigh - globalLow).let { if (it.isNaN() || it.isInfinite() || it <= 0f) 0.01f else it } + val paddedLow = globalLow - priceRange * 0.05f + val paddedHigh = globalHigh + priceRange * 0.05f + val paddedRange = (paddedHigh - paddedLow).let { if (it.isNaN() || it.isInfinite() || it <= 0f) 0.01f else it } + + // ── volume range ───────────────────────────────────────────────── + val maxVolume = if (volumeConfig.enabled) { + data.maxOf { it.volume }.let { if (it.isNaN() || it.isInfinite() || it <= 0f) 1f else it } + } else 1f + + // ── animation ──────────────────────────────────────────────────── + var animationPlayed by remember { mutableStateOf(!animationConfig.enabled) } + + val animationProgress by animateFloatAsState( + targetValue = if (animationPlayed) 1f else 0f, + animationSpec = tween( + durationMillis = animationConfig.durationMillis, + delayMillis = animationConfig.delayMillis, + easing = LinearOutSlowInEasing + ), + label = "candlestickChartAnimation" + ) + + if (animationConfig.enabled) { + LaunchedEffect(Unit) { animationPlayed = true } + } + + // ── axis maths ─────────────────────────────────────────────────── + val yAxisStepHeight = if (yAxisConfig.axisScaleCount > 0) { + chartHeight / yAxisConfig.axisScaleCount + } else chartHeight + + // ── selection state ────────────────────────────────────────────── + var selectedIndex by remember { mutableIntStateOf(-1) } + + val density = LocalDensity.current + val scrollState = rememberScrollState() + + // Total chart height: price area (= chartHeight) + volume area below + val volumeHeight = if (volumeConfig.enabled) { + chartHeight * volumeConfig.heightRatio + } else 0.dp + val totalCanvasHeight = chartHeight + volumeHeight + + // ── Y-axis auto-shrink ────────────────────────────────────────── + val effectiveYAxisConfig = if (autoShrinkYAxisLabels && yAxisConfig.axisScaleCount > 6) { + val scaleFactor = (6f / yAxisConfig.axisScaleCount).coerceIn(0.5f, 1f) + val baseFontSize = yAxisConfig.textStyle.fontSize + val newFontSize = if (baseFontSize.isSp) { + (baseFontSize.value * scaleFactor).sp + } else baseFontSize + yAxisConfig.copy(textStyle = yAxisConfig.textStyle.copy(fontSize = newFontSize)) + } else { + yAxisConfig + } + + Column(modifier = modifier) { + Row(horizontalArrangement = Arrangement.Start) { + + // Y-axis scale + if (effectiveYAxisConfig.isAxisScaleEnabled) { + CandlestickYAxisScale( + yAxisConfig = effectiveYAxisConfig, + yAxisStepHeight = yAxisStepHeight, + minValue = paddedLow, + maxValue = paddedHigh, + chartHeight = chartHeight, + ) + } + + // Chart content + Column(modifier = Modifier.weight(1f)) { + val scrollableContentWidth = if (scrollEnabled && data.size > 1) { + (candleConfig.bodyWidthDp + minCandleSpacing) * data.size + + horizontalDrawPadding * 2 + } else null + + val scrollContentModifier = if (scrollEnabled && scrollableContentWidth != null) { + Modifier + .horizontalScroll(scrollState) + .width(scrollableContentWidth) + } else { + Modifier.fillMaxWidth() + } + + Column(modifier = scrollContentModifier) { + + BoxWithConstraints { + // Grid lines + if (enableGridLines) { + CandlestickYAxisGridLines( + gridLineStyle = gridLineStyle, + yAxisStepHeight = yAxisStepHeight, + totalGridLines = yAxisConfig.axisScaleCount, + ) + } + + // ── Canvas: candles + wicks + volume + crosshair ── + Canvas( + modifier = Modifier + .padding(top = yAxisStepHeight) + .fillMaxWidth() + .height(totalCanvasHeight) + .pointerInput(data) { + detectTapGestures { tapOffset -> + val canvasWidth = size.width.toFloat() + val pointCount = data.size + if (pointCount == 0) return@detectTapGestures + + val horizontalPadding = + with(density) { horizontalDrawPadding.toPx() } + val drawableWidth = canvasWidth - 2 * horizontalPadding + val stepX = if (pointCount > 1) + drawableWidth / (pointCount - 1) + else drawableWidth + + val bodyWidthPx = with(density) { + candleConfig.bodyWidthDp.toPx() + } + val touchSlop = bodyWidthPx * 2f + + var closestIndex = -1 + var closestDist = Float.MAX_VALUE + + data.forEachIndexed { index, _ -> + val cx = horizontalPadding + index * stepX + val dist = abs(tapOffset.x - cx) + if (dist < closestDist) { + closestDist = dist + closestIndex = index + } + } + + if (closestDist <= touchSlop && closestIndex >= 0) { + selectedIndex = closestIndex + onCandleClicked?.invoke(data[closestIndex]) + } else { + selectedIndex = -1 + } + } + } + ) { + val canvasWidth = size.width + val canvasHeight = size.height + val pointCount = data.size + + if (pointCount == 0) return@Canvas + + val horizontalPadding = horizontalDrawPadding.toPx() + val drawableWidth = canvasWidth - 2 * horizontalPadding + val stepX = if (pointCount > 1) + drawableWidth / (pointCount - 1) + else drawableWidth + + val bodyWidthPx = candleConfig.bodyWidthDp.toPx() + val wickWidthPx = candleConfig.wickWidthDp.toPx() + + // Height available for price candles (= chartHeight) + val chartHeightPx = with(density) { chartHeight.toPx() } + val priceAreaHeight = chartHeightPx + + val volumeAreaHeight = canvasHeight - priceAreaHeight + + val volumeAreaTop = priceAreaHeight + + // ── Draw crosshair behind candles ──────── + if (crosshairConfig.enabled && selectedIndex in data.indices) { + val cx = horizontalPadding + selectedIndex * stepX + val entry = data[selectedIndex] + val midY = priceAreaHeight - + ((entry.close - paddedLow) / paddedRange) * + priceAreaHeight + + val dashEffect = if (crosshairConfig.dashLengthDp > 0.dp) { + PathEffect.dashPathEffect( + floatArrayOf( + crosshairConfig.dashLengthDp.toPx(), + crosshairConfig.gapLengthDp.toPx() + ), 0f + ) + } else null + + // Vertical crosshair + drawLine( + color = crosshairConfig.lineColor, + start = Offset(cx, 0f), + end = Offset(cx, priceAreaHeight), + strokeWidth = crosshairConfig.lineWidthDp.toPx(), + pathEffect = dashEffect, + ) + // Horizontal crosshair at close price + drawLine( + color = crosshairConfig.lineColor, + start = Offset(0f, midY), + end = Offset(canvasWidth, midY), + strokeWidth = crosshairConfig.lineWidthDp.toPx(), + pathEffect = dashEffect, + ) + } + + // ── Draw candles ───────────────────────── + val visibleCount = + (pointCount * animationProgress).toInt() + .coerceIn(0, pointCount) + + for (i in 0 until visibleCount) { + val entry = data[i] + val cx = horizontalPadding + i * stepX + + val candleColor = when (entry.change) { + CandleChange.Bullish -> candleConfig.bullishColor + CandleChange.Bearish -> candleConfig.bearishColor + CandleChange.Neutral -> candleConfig.neutralColor + } + + // Map prices to Y coordinates (inverted: high price = low Y) + val highY = priceAreaHeight - + ((entry.high - paddedLow) / paddedRange) * priceAreaHeight + val lowY = priceAreaHeight - + ((entry.low - paddedLow) / paddedRange) * priceAreaHeight + val openY = priceAreaHeight - + ((entry.open - paddedLow) / paddedRange) * priceAreaHeight + val closeY = priceAreaHeight - + ((entry.close - paddedLow) / paddedRange) * priceAreaHeight + + val bodyTop = minOf(openY, closeY) + val bodyBottom = maxOf(openY, closeY) + // Ensure min body height of 1px + val minBodyHeight = 1f + val bodyHeight = (bodyBottom - bodyTop) + .coerceAtLeast(minBodyHeight) + + // ── Top wick ───────────────────────── + drawLine( + color = candleColor, + start = Offset(cx, highY), + end = Offset(cx, bodyTop), + strokeWidth = wickWidthPx, + cap = candleConfig.wickCap, + ) + + // ── Bottom wick ────────────────────── + drawLine( + color = candleColor, + start = Offset(cx, bodyBottom), + end = Offset(cx, lowY), + strokeWidth = wickWidthPx, + cap = candleConfig.wickCap, + ) + + // ── Candle body ────────────────────── + if (candleConfig.hollowBullish && + entry.change == CandleChange.Bullish + ) { + // Hollow body (outline only) + drawRect( + color = candleColor, + topLeft = Offset( + cx - bodyWidthPx / 2, + bodyTop + ), + size = Size(bodyWidthPx, bodyHeight), + style = Stroke(width = wickWidthPx), + ) + } else { + // Filled body + drawRect( + color = candleColor, + topLeft = Offset( + cx - bodyWidthPx / 2, + bodyTop + ), + size = Size(bodyWidthPx, bodyHeight), + ) + } + + // ── Selection highlight ────────────── + if (i == selectedIndex) { + drawRect( + color = candleColor.copy(alpha = 0.15f), + topLeft = Offset( + cx - bodyWidthPx, + highY + ), + size = Size( + bodyWidthPx * 2, + lowY - highY + ), + ) + } + } + + // ── Draw volume bars ───────────────────── + if (volumeConfig.enabled) { + for (i in 0 until visibleCount) { + val entry = data[i] + val cx = horizontalPadding + i * stepX + + val volColor = when (entry.change) { + CandleChange.Bullish -> volumeConfig.bullishColor + CandleChange.Bearish -> volumeConfig.bearishColor + CandleChange.Neutral -> volumeConfig.neutralColor + } + + val barWidth = + (volumeConfig.barWidthDp ?: candleConfig.bodyWidthDp) + .toPx() + val barHeight = (entry.volume / maxVolume) * + volumeAreaHeight * 0.9f // 90% fill + + drawRect( + color = volColor, + topLeft = Offset( + cx - barWidth / 2, + volumeAreaTop + volumeAreaHeight - barHeight + ), + size = Size(barWidth, barHeight), + ) + } + } + } + + // ── Marker tooltip ──────────────────────────── + if (selectedIndex in data.indices && markerConfig.enabled) { + val entry = data[selectedIndex] + + // Calculate popup offset to position near the selected candle + val pCount = data.size + val hPaddingPx = with(density) { horizontalDrawPadding.toPx() } + val cWidthPx = with(density) { maxWidth.toPx() } + val dWidth = cWidthPx - 2 * hPaddingPx + val sX = if (pCount > 1) dWidth / (pCount - 1) else dWidth + val candleX = hPaddingPx + selectedIndex * sX + + // Candle high-Y in canvas coords, then absolute Y from BoxWithConstraints top + val chartHeightPx = with(density) { chartHeight.toPx() } + val yAxisStepPx = with(density) { yAxisStepHeight.toPx() } + val highY = chartHeightPx - + ((entry.high - paddedLow) / paddedRange) * chartHeightPx + + val rawPopupOffsetX = (candleX - cWidthPx / 2f).toInt() + val estimatedHalfWidthPx = with(density) { 80.dp.toPx() } + val maxAbsOffsetX = ((cWidthPx / 2f) - estimatedHalfWidthPx) + .coerceAtLeast(0f).toInt() + val popupOffsetX = rawPopupOffsetX.coerceIn(-maxAbsOffsetX, maxAbsOffsetX) + + val pointYFromTop = yAxisStepPx + highY + val popupMargin = with(density) { 48.dp.toPx() } + val popupOffsetY = (pointYFromTop - popupMargin).toInt().coerceAtLeast(0) + + if (customMarker != null) { + customMarker(entry) { selectedIndex = -1 } + } else { + CandlestickMarkerPopup( + markerConfig = markerConfig, + entry = entry, + offset = IntOffset(popupOffsetX, popupOffsetY), + onDismissRequest = { selectedIndex = -1 }, + ) + } + } + } + + // ── X-axis line ────────────────────────────────── + if (xAxisConfig.isAxisLineEnabled) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(xAxisConfig.axisLineWidth) + .clip(xAxisConfig.axisLineShape) + .background(xAxisConfig.axisLineColor) + ) + } + + // ── X-axis labels ──────────────────────────────── + if (xAxisConfig.isAxisScaleEnabled) { + Spacer(modifier = Modifier.height(4.dp)) + + val labelTextStyle = if (autoShrinkXAxisLabels && data.size > 8) { + val scaleFactor = (8f / data.size).coerceIn(0.5f, 1f) + val baseFontSize = xAxisConfig.textStyle.fontSize + val newFontSize = if (baseFontSize.isSp) { + (baseFontSize.value * scaleFactor).sp + } else baseFontSize + xAxisConfig.textStyle.copy(fontSize = newFontSize) + } else { + xAxisConfig.textStyle + } + + Box(modifier = Modifier.fillMaxWidth()) { + data.forEachIndexed { index, entry -> + val displayText = if (entry.label.length > maxTextLengthXAxis) { + "${ + entry.label.take( + if (maxTextLengthXAxis > 5) + (maxTextLengthXAxis - 2) + else maxTextLengthXAxis + ) + }.." + } else { + entry.label + } + + val fraction = if (data.size > 1) { + index.toFloat() / (data.size - 1) + } else 0.5f + + Text( + text = displayText, + style = labelTextStyle, + modifier = Modifier + .align(Alignment.TopStart) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + val horizontalPaddingPx = + horizontalDrawPadding.toPx() + val availableWidth = + constraints.maxWidth.toFloat() + val drawableWidth = + availableWidth - 2 * horizontalPaddingPx + val xPos = horizontalPaddingPx + + fraction * drawableWidth - + placeable.width / 2 + placeable.placeRelative( + x = xPos.toInt(), + y = 0 + ) + } + }, + maxLines = 1, + ) + } + } + } + } + } + } + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/components/CandlestickGridLines.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/components/CandlestickGridLines.kt new file mode 100644 index 0000000..69ea6f4 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/components/CandlestickGridLines.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.candlestickchart.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.unit.Dp +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickGridLineStyle + +/** + * Draws a single horizontal dashed grid line for the candlestick chart. + */ +@Composable +internal fun CandlestickGridLine( + modifier: Modifier = Modifier, + gridLineStyle: CandlestickGridLineStyle, +) { + Canvas(modifier = modifier.padding(top = gridLineStyle.strokeWidth / 2)) { + val dashLengthPx = gridLineStyle.dashLength.toPx() + val gapLengthPx = gridLineStyle.gapLength.toPx() + val strokeWidthPx = gridLineStyle.strokeWidth.toPx() + + // Skia's MakeDash requires all intervals > 0; passing 0 returns + // nullptr which crashes on Kotlin/Native ("Can't wrap nullptr"). + val pathEffect = if (dashLengthPx > 0f && gapLengthPx > 0f) { + PathEffect.dashPathEffect( + floatArrayOf(dashLengthPx, gapLengthPx), 0f + ) + } else null + drawLine( + color = gridLineStyle.color, + start = Offset(0f, size.height / 2), + end = Offset(size.width, size.height / 2), + strokeWidth = strokeWidthPx, + pathEffect = pathEffect, + cap = gridLineStyle.dashCap, + ) + } +} + +/** + * Stacks [CandlestickGridLine] composables vertically inside a [Column], + * matching the layout approach used by [com.developerstring.jetco_kmp.charts.linegraph.components.LineGraphYAxisGridLines]. + * + * @param gridLineStyle Visual configuration for each grid line. + * @param yAxisStepHeight Vertical distance between consecutive grid lines. + * @param totalGridLines Override for the number of grid lines. Defaults + * to [CandlestickGridLineStyle.totalGridLines]. + */ +@Composable +internal fun CandlestickYAxisGridLines( + gridLineStyle: CandlestickGridLineStyle, + yAxisStepHeight: Dp, + totalGridLines: Int = gridLineStyle.totalGridLines, +) { + Column( + modifier = Modifier + .padding(top = yAxisStepHeight) + .fillMaxWidth(), + ) { + repeat(totalGridLines + 1) { index -> + if (index != 0) { + Row( + modifier = Modifier.height(height = yAxisStepHeight), + verticalAlignment = Alignment.Top + ) { + CandlestickGridLine( + modifier = Modifier.fillMaxWidth(), + gridLineStyle = gridLineStyle, + ) + } + } + } + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/components/CandlestickMarkerPopup.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/components/CandlestickMarkerPopup.kt new file mode 100644 index 0000000..bebd478 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/components/CandlestickMarkerPopup.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.candlestickchart.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickMarkerConfig +import com.developerstring.jetco_kmp.charts.candlestickchart.model.CandlestickEntry +import kotlin.math.abs +import kotlin.math.round + +/** + * Default OHLC marker tooltip for the candlestick chart. + * + * Shows Open, High, Low, Close (and volume when > 0) in a + * compact popup aligned at the centre of the chart. + */ +@Composable +internal fun CandlestickMarkerPopup( + markerConfig: CandlestickMarkerConfig, + entry: CandlestickEntry, + offset: IntOffset = IntOffset.Zero, + onDismissRequest: () -> Unit, +) { + Popup( + alignment = Alignment.TopCenter, + offset = offset, + onDismissRequest = onDismissRequest, + ) { + Box( + modifier = Modifier + .clip(markerConfig.shape) + .background(markerConfig.background) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Column { + Text( + text = entry.label, + style = markerConfig.textStyle.copy( + color = markerConfig.textStyle.color.copy(alpha = 0.7f) + ), + ) + Text( + text = "O ${formatMarkerValue(entry.open)} " + + "C ${formatMarkerValue(entry.close)}", + style = markerConfig.textStyle, + ) + Text( + text = "L ${formatMarkerValue(entry.low)} " + + "H ${formatMarkerValue(entry.high)}", + style = markerConfig.textStyle, + ) + if (entry.volume > 0f) { + Text( + text = "Vol ${formatMarkerVolume(entry.volume)}", + style = markerConfig.textStyle.copy( + color = markerConfig.textStyle.color.copy(alpha = 0.7f) + ), + ) + } + } + } + } +} + +private fun formatMarkerValue(value: Float): String { + if (value.isNaN() || value.isInfinite()) return "0" + val negative = value < 0f + val absRounded = round(abs(value) * 100).toLong() + val intPart = absRounded / 100 + val decPart = (absRounded % 100).toInt() + val prefix = if (negative && (intPart > 0 || decPart > 0)) "-" else "" + return when { + decPart == 0 -> "$prefix$intPart" + decPart % 10 == 0 -> "$prefix$intPart.${decPart / 10}" + else -> "$prefix$intPart.${decPart.toString().padStart(2, '0')}" + } +} + +private fun formatMarkerVolume(value: Float): String { + return when { + value >= 1_000_000_000 -> "${formatMarkerValue(value / 1_000_000_000)}B" + value >= 1_000_000 -> "${formatMarkerValue(value / 1_000_000)}M" + value >= 1_000 -> "${formatMarkerValue(value / 1_000)}K" + else -> formatMarkerValue(value) + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/components/CandlestickYAxisScale.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/components/CandlestickYAxisScale.kt new file mode 100644 index 0000000..6dc40e6 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/components/CandlestickYAxisScale.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.candlestickchart.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.charts.candlestickchart.config.CandlestickYAxisConfig +import kotlin.math.abs +import kotlin.math.round + +/** + * Y-axis labels and optional axis line for the candlestick chart. + * + * The scale spans from [minValue] to [maxValue] divided into + * [CandlestickYAxisConfig.axisScaleCount] steps. + */ +@Composable +internal fun CandlestickYAxisScale( + yAxisConfig: CandlestickYAxisConfig, + yAxisStepHeight: Dp, + minValue: Float, + maxValue: Float, + chartHeight: Dp, +) { + val range = maxValue - minValue + val scaleStep = if (yAxisConfig.axisScaleCount > 0) { + range / yAxisConfig.axisScaleCount + } else range + + Row { + Column(horizontalAlignment = Alignment.End) { + repeat(yAxisConfig.axisScaleCount + 1) { index -> + val barScale = yAxisConfig.axisScaleCount - index + val value = minValue + scaleStep * barScale + Row( + modifier = Modifier.height(height = yAxisStepHeight), + verticalAlignment = Alignment.Bottom, + ) { + Text( + text = yAxisConfig.textPrefix + + formatCandlestickScaleValue(value) + + yAxisConfig.textPostfix, + style = yAxisConfig.textStyle, + ) + } + } + } + + if (yAxisConfig.isAxisLineEnabled) { + Spacer(modifier = Modifier.width(10.dp)) + + Box( + modifier = Modifier + .padding(top = yAxisStepHeight) + .clip(shape = yAxisConfig.axisLineShape) + .width(yAxisConfig.axisLineWidth) + .height(chartHeight) + .background(yAxisConfig.axisLineColor) + ) + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ Formatting helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━ + +private fun formatDecimal(value: Float): String { + if (value.isNaN() || value.isInfinite()) return "0" + val negative = value < 0f + val absRounded = round(abs(value) * 100).toLong() + val intPart = absRounded / 100 + val decPart = (absRounded % 100).toInt() + val prefix = if (negative && (intPart > 0 || decPart > 0)) "-" else "" + return when { + decPart == 0 -> "$prefix$intPart" + decPart % 10 == 0 -> "$prefix$intPart.${decPart / 10}" + else -> "$prefix$intPart.${decPart.toString().padStart(2, '0')}" + } +} + +internal fun formatCandlestickScaleValue(value: Float): String { + if (value < 10_000) return formatDecimal(value) + val am: Float + if (abs(value / 1_000_000) >= 1) { + am = value / 1_000_000 + return formatDecimal(am) + "M" + } else if (abs(value / 1_000) >= 1) { + am = value / 1_000 + return formatDecimal(am) + "K" + } + return formatDecimal(value) +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/config/CandlestickConfigurations.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/config/CandlestickConfigurations.kt new file mode 100644 index 0000000..886d641 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/config/CandlestickConfigurations.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.candlestickchart.config + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Candle body and wick appearance. + * + * @property bullishColor Color for candles where close > open. + * @property bearishColor Color for candles where close < open. + * @property neutralColor Color for candles where close == open. + * @property bodyWidthDp Width of the candle body in dp. + * @property wickWidthDp Width of the wick line in dp. + * @property wickCap [StrokeCap] applied to wick ends. + * @property hollowBullish When `true`, bullish candles are rendered + * with a hollow (outlined) body instead of filled. + */ +@Stable +data class CandlestickCandleConfig( + val bullishColor: Color, + val bearishColor: Color, + val neutralColor: Color, + val bodyWidthDp: Dp, + val wickWidthDp: Dp, + val wickCap: StrokeCap, + val hollowBullish: Boolean, +) + +/** + * Crosshair indicator shown when a candle is selected. + * + * @property enabled Whether the crosshair is drawn. + * @property lineColor Color of the crosshair lines. + * @property lineWidthDp Width of the crosshair lines. + * @property dashLengthDp Dash segment length (0 = solid). + * @property gapLengthDp Gap between dash segments. + */ +@Stable +data class CandlestickCrosshairConfig( + val enabled: Boolean, + val lineColor: Color, + val lineWidthDp: Dp, + val dashLengthDp: Dp, + val gapLengthDp: Dp, +) + +/** + * Marker tooltip shown when a candle is tapped/selected. + * + * @property enabled Whether the marker is shown. + * @property background Background color of the tooltip. + * @property shape Shape of the tooltip container. + * @property textStyle Style used for OHLC labels inside the tooltip. + */ +@Stable +data class CandlestickMarkerConfig( + val enabled: Boolean, + val background: Color, + val shape: Shape, + val textStyle: TextStyle, +) + +/** + * Volume-bar overlay rendered below the candles. + * + * @property enabled Whether volume bars are drawn. + * @property heightRatio Fraction of the chart height occupied by volume bars + * (e.g., `0.2f` = 20 %). + * @property bullishColor Volume bar color for bullish candles. + * @property bearishColor Volume bar color for bearish candles. + * @property neutralColor Volume bar color for neutral candles. + * @property barWidthDp Width of each volume bar; when `null` the + * candle body width is used. + */ +@Stable +data class CandlestickVolumeConfig( + val enabled: Boolean, + val heightRatio: Float, + val bullishColor: Color, + val bearishColor: Color, + val neutralColor: Color, + val barWidthDp: Dp?, +) + +/** + * X-axis configuration for the candlestick chart. + * + * @property isAxisScaleEnabled Whether X-axis labels are displayed. + * @property isAxisLineEnabled Whether the horizontal line is drawn. + * @property axisLineWidth Thickness of the axis line. + * @property axisLineShape Shape applied to the line. + * @property axisLineColor Color of the axis line. + * @property textStyle Style for labels. + */ +@Stable +data class CandlestickXAxisConfig( + val isAxisScaleEnabled: Boolean, + val isAxisLineEnabled: Boolean, + val axisLineWidth: Dp, + val axisLineShape: Shape, + val axisLineColor: Color, + val textStyle: TextStyle, +) + +/** + * Y-axis configuration for the candlestick chart. + * + * @property isAxisScaleEnabled Whether Y-axis labels are displayed. + * @property isAxisLineEnabled Whether the vertical line is drawn. + * @property axisLineWidth Thickness of the axis line. + * @property axisLineShape Shape applied to the line. + * @property axisLineColor Color of the axis line. + * @property axisScaleCount Number of divisions on the Y-axis. + * @property textStyle Style for labels. + * @property textPrefix Prefix added before label values (e.g., "$"). + * @property textPostfix Postfix added after label values. + */ +@Stable +data class CandlestickYAxisConfig( + val isAxisScaleEnabled: Boolean, + val isAxisLineEnabled: Boolean, + val axisLineWidth: Dp, + val axisLineShape: Shape, + val axisLineColor: Color, + val axisScaleCount: Int, + val textStyle: TextStyle, + val textPrefix: String, + val textPostfix: String, +) + +/** + * Horizontal grid-line styling. + * + * @property color Grid-line color. + * @property strokeWidth Line thickness. + * @property dashLength Dash segment length. + * @property gapLength Gap between dashes. + * @property totalGridLines Number of horizontal lines. + * @property dashCap Cap style applied to each dash. + */ +@Stable +data class CandlestickGridLineStyle( + val color: Color, + val strokeWidth: Dp, + val dashLength: Dp = 0.dp, + val gapLength: Dp = 0.dp, + val totalGridLines: Int = 4, + val dashCap: StrokeCap = StrokeCap.Square, +) + +/** + * Entrance animation timing for the candlestick chart. + * + * @property enabled Whether fade-in / pop-in animation is played. + * @property durationMillis Total animation duration. + * @property delayMillis Delay before animation starts. + */ +@Stable +data class CandlestickAnimationConfig( + val enabled: Boolean, + val durationMillis: Int, + val delayMillis: Int = 0, +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/config/CandlestickDefaults.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/config/CandlestickDefaults.kt new file mode 100644 index 0000000..6fbd9b2 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/config/CandlestickDefaults.kt @@ -0,0 +1,227 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.candlestickchart.config + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Factory functions with sensible defaults for the [com.developerstring.jetco_kmp.charts.candlestickchart.CandlestickChart]. + * + * Example: + * ``` + * CandlestickChart( + * data = entries, + * candleConfig = CandlestickDefaults.candleConfig(bullishColor = Color.Green), + * ) + * ``` + */ +object CandlestickDefaults { + + // ── shared constants ───────────────────────────────────────────────── + + private val textStyle = TextStyle( + fontSize = 14.sp, + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + color = Color.Black, + ) + + /** Standard bullish (up) green. */ + val defaultBullishColor = Color(0xFF26A69A) + + /** Standard bearish (down) red. */ + val defaultBearishColor = Color(0xFFEF5350) + + /** Neutral grey. */ + val defaultNeutralColor = Color(0xFF9E9E9E) + + // ── candle ─────────────────────────────────────────────────────────── + + /** + * Creates a [CandlestickCandleConfig]. + */ + fun candleConfig( + bullishColor: Color = defaultBullishColor, + bearishColor: Color = defaultBearishColor, + neutralColor: Color = defaultNeutralColor, + bodyWidthDp: Dp = 8.dp, + wickWidthDp: Dp = 1.5.dp, + wickCap: StrokeCap = StrokeCap.Round, + hollowBullish: Boolean = false, + ) = CandlestickCandleConfig( + bullishColor = bullishColor, + bearishColor = bearishColor, + neutralColor = neutralColor, + bodyWidthDp = bodyWidthDp, + wickWidthDp = wickWidthDp, + wickCap = wickCap, + hollowBullish = hollowBullish, + ) + + // ── crosshair ──────────────────────────────────────────────────────── + + /** + * Creates a [CandlestickCrosshairConfig]. + */ + fun crosshairConfig( + enabled: Boolean = true, + lineColor: Color = Color.Gray, + lineWidthDp: Dp = 1.dp, + dashLengthDp: Dp = 6.dp, + gapLengthDp: Dp = 4.dp, + ) = CandlestickCrosshairConfig( + enabled = enabled, + lineColor = lineColor, + lineWidthDp = lineWidthDp, + dashLengthDp = dashLengthDp, + gapLengthDp = gapLengthDp, + ) + + // ── marker / tooltip ───────────────────────────────────────────────── + + /** + * Creates a [CandlestickMarkerConfig]. + */ + fun markerConfig( + enabled: Boolean = true, + background: Color = Color(0xFF333333), + shape: Shape = RoundedCornerShape(8.dp), + textStyle: TextStyle = CandlestickDefaults.textStyle.copy( + color = Color.White, + fontSize = 12.sp + ), + ) = CandlestickMarkerConfig( + enabled = enabled, + background = background, + shape = shape, + textStyle = textStyle, + ) + + // ── volume ─────────────────────────────────────────────────────────── + + /** + * Creates a [CandlestickVolumeConfig]. + */ + fun volumeConfig( + enabled: Boolean = true, + heightRatio: Float = 0.2f, + bullishColor: Color = defaultBullishColor.copy(alpha = 0.4f), + bearishColor: Color = defaultBearishColor.copy(alpha = 0.4f), + neutralColor: Color = defaultNeutralColor.copy(alpha = 0.4f), + barWidthDp: Dp? = null, + ) = CandlestickVolumeConfig( + enabled = enabled, + heightRatio = heightRatio, + bullishColor = bullishColor, + bearishColor = bearishColor, + neutralColor = neutralColor, + barWidthDp = barWidthDp, + ) + + // ── x-axis ─────────────────────────────────────────────────────────── + + /** + * Creates a [CandlestickXAxisConfig]. + */ + fun xAxisConfig( + isAxisScaleEnabled: Boolean = true, + isAxisLineEnabled: Boolean = true, + axisLineWidth: Dp = 2.dp, + axisLineShape: Shape = RoundedCornerShape(3.dp), + axisLineColor: Color = Color.LightGray, + textStyle: TextStyle = CandlestickDefaults.textStyle, + ) = CandlestickXAxisConfig( + isAxisScaleEnabled = isAxisScaleEnabled, + isAxisLineEnabled = isAxisLineEnabled, + axisLineWidth = axisLineWidth, + axisLineShape = axisLineShape, + axisLineColor = axisLineColor, + textStyle = textStyle, + ) + + // ── y-axis ─────────────────────────────────────────────────────────── + + /** + * Creates a [CandlestickYAxisConfig]. + */ + fun yAxisConfig( + isAxisScaleEnabled: Boolean = true, + isAxisLineEnabled: Boolean = true, + axisLineWidth: Dp = 2.dp, + axisLineShape: Shape = RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp), + axisLineColor: Color = Color.LightGray, + axisScaleCount: Int = 4, + textStyle: TextStyle = CandlestickDefaults.textStyle, + textPrefix: String = "", + textPostfix: String = "", + ) = CandlestickYAxisConfig( + isAxisScaleEnabled = isAxisScaleEnabled, + isAxisLineEnabled = isAxisLineEnabled, + axisLineWidth = axisLineWidth, + axisLineShape = axisLineShape, + axisLineColor = axisLineColor, + axisScaleCount = axisScaleCount, + textStyle = textStyle, + textPrefix = textPrefix, + textPostfix = textPostfix, + ) + + // ── grid lines ─────────────────────────────────────────────────────── + + /** + * Creates a [CandlestickGridLineStyle]. + */ + fun gridLineStyle( + color: Color = Color.LightGray, + strokeWidth: Dp = 1.dp, + dashLength: Dp = 8.dp, + gapLength: Dp = 8.dp, + totalGridLines: Int = 4, + dashCap: StrokeCap = StrokeCap.Square, + ) = CandlestickGridLineStyle( + color = color, + strokeWidth = strokeWidth, + dashLength = dashLength, + gapLength = gapLength, + totalGridLines = totalGridLines, + dashCap = dashCap, + ) + + // ── animation ──────────────────────────────────────────────────────── + + /** + * Creates a [CandlestickAnimationConfig]. + */ + fun animationConfig( + enabled: Boolean = true, + durationMillis: Int = 800, + delayMillis: Int = 0, + ) = CandlestickAnimationConfig( + enabled = enabled, + durationMillis = durationMillis, + delayMillis = delayMillis, + ) +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/model/CandlestickEntry.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/model/CandlestickEntry.kt new file mode 100644 index 0000000..c3ba60b --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/candlestickchart/model/CandlestickEntry.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.candlestickchart.model + +import androidx.compose.runtime.Stable + +/** + * Represents the absolute price change of a candle. + * + * - [Bullish] → closing > opening (price went up) + * - [Bearish] → closing < opening (price went down) + * - [Neutral] → closing == opening (no change) + */ +enum class CandleChange { + Bullish, + Bearish, + Neutral; + + internal companion object { + fun forPrices(opening: Float, closing: Float): CandleChange = + when { + closing > opening -> Bullish + closing < opening -> Bearish + else -> Neutral + } + } +} + +/** + * Represents a single candle in a candlestick chart. + * + * Each entry contains the standard OHLC (Open, High, Low, Close) + * data required to render a candlestick, plus an optional volume + * value for volume-bar overlays. + * + * @property label X-axis label for this candle (e.g., date, time). + * @property open The opening price. + * @property high The highest price. + * @property low The lowest price. + * @property close The closing price. + * @property volume Optional trading volume for this period. + * @property change The computed price direction ([CandleChange]). + * + * Example: + * ``` + * val candle = CandlestickEntry( + * label = "Jan 5", + * open = 150f, + * high = 160f, + * low = 145f, + * close = 155f, + * volume = 1_200_000f, + * ) + * ``` + */ +@Stable +data class CandlestickEntry( + val label: String, + val open: Float, + val high: Float, + val low: Float, + val close: Float, + val volume: Float = 0f, + val change: CandleChange = CandleChange.forPrices(open, close), +) { + init { + require(low <= open && low <= close && low <= high) { + "`low` ($low) can't be greater than `open`, `close`, or `high`." + } + require(high >= open && high >= close) { + "`high` ($high) can't be less than `open` or `close`." + } + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/LineGraph.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/LineGraph.kt new file mode 100644 index 0000000..7a5db1b --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/LineGraph.kt @@ -0,0 +1,909 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.linegraph + +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.developerstring.jetco_kmp.charts.linegraph.components.LineGraphPopup +import com.developerstring.jetco_kmp.charts.linegraph.components.LineGraphYAxisGridLines +import com.developerstring.jetco_kmp.charts.linegraph.components.LineGraphYAxisScale +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphAnimationConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphAreaFillConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphDefaults +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphGridLineStyle +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphLineConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphLiveUpdateConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphPointConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphPopUpConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphXAxisConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphYAxisConfig +import com.developerstring.jetco_kmp.charts.linegraph.model.LineGraphEntry +import com.developerstring.jetco_kmp.charts.linegraph.model.mapToLineGraphEntries +import kotlin.math.abs +import kotlin.math.round + +/** + * A fully customisable, animated line graph composable for Jetpack Compose. + * + * The graph draws a continuous line connecting data points, with optional + * area fill, data-point dots, grid lines, axes, tooltips and entrance + * animation. The line can be rendered as straight segments or smooth + * cubic Bézier curves (inspired by Vico's `CubicPointConnector`). + * + * ## Quick start + * ```kotlin + * LineGraph( + * chartData = mapOf("Jan" to 10f, "Feb" to 25f, "Mar" to 18f, "Apr" to 30f), + * ) + * ``` + * + * ## Full customisation + * ```kotlin + * LineGraph( + * modifier = Modifier.fillMaxWidth().padding(16.dp), + * chartData = mapOf("Q1" to 120f, "Q2" to 340f, "Q3" to 260f, "Q4" to 410f), + * chartHeight = 250.dp, + * lineConfig = LineGraphDefaults.lineConfig( + * lineColor = Color(0xFFEF5350), + * smoothCurve = true, + * curvature = 0.4f + * ), + * areaFillConfig = LineGraphDefaults.areaFillConfig( + * enabled = true, + * lineColor = Color(0xFFEF5350) + * ), + * pointConfig = LineGraphDefaults.pointConfig(borderColor = Color(0xFFEF5350)), + * yAxisConfig = LineGraphDefaults.yAxisConfig(textPrefix = "$"), + * animationConfig = LineGraphDefaults.animationConfig(durationMillis = 1500), + * onPointClicked = { label, value -> Log.d("Chart", "$label → $value") } + * ) + * ``` + * + * @param modifier Modifier applied to the outermost container. + * @param chartData A **map** of label → value pairs that define the graph. + * Insertion order is preserved and used as the X-axis order. + * @param chartHeight The height of the drawing area (excluding axis labels). + * @param lineConfig Visual configuration for the line stroke. + * Default: [LineGraphDefaults.lineConfig]. + * @param areaFillConfig Controls the gradient fill below the line. + * Default: [LineGraphDefaults.areaFillConfig]. + * @param pointConfig Controls the data-point dots. + * Default: [LineGraphDefaults.pointConfig]. + * @param yAxisConfig Y-axis labels and axis line. + * Default: [LineGraphDefaults.yAxisConfig]. + * @param xAxisConfig X-axis labels and axis line. + * Default: [LineGraphDefaults.xAxisConfig]. + * @param gridLineStyle Horizontal dashed grid lines. + * Default: [LineGraphDefaults.gridLineStyle]. + * @param popUpConfig Tooltip shown when a data point is tapped. + * Default: [LineGraphDefaults.popUpConfig]. + * @param animationConfig Entrance animation timing. + * Default: [LineGraphDefaults.animationConfig]. + * @param maxValue Optional ceiling for the Y-axis. When `null` the + * maximum value in [chartData] is used. + * @param enableGridLines Whether to draw horizontal grid lines. + * @param maxTextLengthXAxis X-axis labels longer than this are truncated. + * @param enableTextRotate Whether X-axis labels are rotated. + * @param textRotateAngle Rotation angle in degrees (only when + * [enableTextRotate] is `true`). + * @param horizontalDrawPadding Horizontal space between the Y-axis + * and the first / last data point. + * Defaults to `0.dp` (line starts right + * at the axis). Increase for visual + * breathing room. + * @param scrollEnabled When `true` the chart area becomes horizontally + * scrollable — useful for large data sets. + * @param minPointSpacing Minimum horizontal spacing between data points + * when [scrollEnabled] is `true`. The chart will + * expand beyond the viewport to honour this value. + * @param autoShrinkXAxisLabels When `true`, X-axis label font size is + * automatically reduced to fit within the + * available width when there are many labels. + * @param autoShrinkYAxisLabels When `true`, Y-axis label font size is + * automatically reduced when there are many + * scale steps. + * @param liveUpdateConfig Configuration for real-time data updates with + * blinking last-point indicator and smooth path + * transitions. Default: [LineGraphDefaults.liveUpdateConfig]. + * @param customPointMarker Optional custom composable for rendering data point markers. + * Receives label, value, and offset position. + * When `null`, default circle markers are drawn. + * @param customPopup Optional custom composable for the tooltip popup. + * Receives label, value, and dismiss callback. + * When `null`, default popup is shown. + * @param onPointClicked Callback invoked when a data point is tapped, + * receiving the label and raw value. + * + * @see LineGraphDefaults + */ +@Composable +fun LineGraph( + modifier: Modifier = Modifier, + chartData: Map, + chartHeight: Dp = 200.dp, + lineConfig: LineGraphLineConfig = LineGraphDefaults.lineConfig(), + areaFillConfig: LineGraphAreaFillConfig = LineGraphDefaults.areaFillConfig(), + pointConfig: LineGraphPointConfig = LineGraphDefaults.pointConfig(), + yAxisConfig: LineGraphYAxisConfig = LineGraphDefaults.yAxisConfig(), + xAxisConfig: LineGraphXAxisConfig = LineGraphDefaults.xAxisConfig(), + gridLineStyle: LineGraphGridLineStyle = LineGraphDefaults.gridLineStyle(), + popUpConfig: LineGraphPopUpConfig = LineGraphDefaults.popUpConfig(), + animationConfig: LineGraphAnimationConfig = LineGraphDefaults.animationConfig(), + liveUpdateConfig: LineGraphLiveUpdateConfig = LineGraphDefaults.liveUpdateConfig(), + maxValue: Float? = null, + enableGridLines: Boolean = true, + maxTextLengthXAxis: Int = 6, + enableTextRotate: Boolean = false, + textRotateAngle: Float = -60f, + horizontalDrawPadding: Dp = 0.dp, + scrollEnabled: Boolean = false, + minPointSpacing: Dp = 48.dp, + autoShrinkXAxisLabels: Boolean = false, + autoShrinkYAxisLabels: Boolean = false, + customPointMarker: (@Composable (label: String, value: Float, offset: Offset) -> Unit)? = null, + customPopup: (@Composable (label: String, value: Float, onDismiss: () -> Unit) -> Unit)? = null, + onPointClicked: ((Pair) -> Unit)? = null, +) { + // ── data preparation ───────────────────────────────────────────── + val maxDataValue: Float = maxValue ?: chartData.values.maxOrNull() ?: 0f + val entries: List = chartData.mapToLineGraphEntries(maxDataValue) + + if (entries.isEmpty()) return + + // ── animation state ────────────────────────────────────────────── + var animationPlayed by remember { mutableStateOf(!animationConfig.enabled) } + + val animationProgress by animateFloatAsState( + targetValue = if (animationPlayed) 1f else 0f, + animationSpec = tween( + durationMillis = animationConfig.durationMillis, + delayMillis = animationConfig.delayMillis, + easing = LinearOutSlowInEasing + ), + label = "lineGraphAnimation" + ) + + if (animationConfig.enabled) { + LaunchedEffect(Unit) { animationPlayed = true } + } + + // ── live-update: animated data values ──────────────────────────── + // When liveUpdateConfig is enabled, each entry's normalizedY is + // animated so the path smoothly transitions to new positions. + val animatedNormalizedYValues = entries.map { entry -> + if (liveUpdateConfig.enabled) { + animateFloatAsState( + targetValue = entry.normalizedY, + animationSpec = tween( + durationMillis = liveUpdateConfig.pathTransitionDurationMillis, + easing = LinearOutSlowInEasing + ), + label = "liveY_${entry.label}" + ).value + } else { + entry.normalizedY + } + } + + // ── live-update: blink animation ───────────────────────────────── + val blinkRadius = if (liveUpdateConfig.enabled && liveUpdateConfig.blinkEnabled) { + val infiniteTransition = rememberInfiniteTransition(label = "blinkTransition") + infiniteTransition.animateFloat( + initialValue = liveUpdateConfig.blinkMinRadius, + targetValue = liveUpdateConfig.blinkMaxRadius, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = liveUpdateConfig.blinkDurationMillis, + easing = LinearOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "blinkRadius" + ).value + } else { + 0f + } + + val blinkAlpha = if (liveUpdateConfig.enabled && liveUpdateConfig.blinkEnabled) { + val infiniteTransition = rememberInfiniteTransition(label = "blinkAlphaTransition") + infiniteTransition.animateFloat( + initialValue = 0.8f, + targetValue = 0.2f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = liveUpdateConfig.blinkDurationMillis, + easing = LinearOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "blinkAlpha" + ).value + } else { + 0f + } + + // ── axis maths ─────────────────────────────────────────────────── + val yAxisScaleStep = if (yAxisConfig.axisScaleCount > 0) { + maxDataValue / yAxisConfig.axisScaleCount + } else maxDataValue + + val yAxisStepHeight = if (yAxisConfig.axisScaleCount > 0) { + chartHeight / yAxisConfig.axisScaleCount + } else chartHeight + + // ── tooltip state ──────────────────────────────────────────────── + var selectedPointIndex by remember { mutableIntStateOf(-1) } + + // ── density for Dp → Px ───────────────────────────────────────── + val density = LocalDensity.current + + // ── horizontal scroll state ───────────────────────────────────── + val scrollState = rememberScrollState() + + // ── Y-axis auto-shrink ────────────────────────────────────────── + val effectiveYAxisConfig = if (autoShrinkYAxisLabels && yAxisConfig.axisScaleCount > 6) { + val scaleFactor = (6f / yAxisConfig.axisScaleCount).coerceIn(0.5f, 1f) + val baseFontSize = yAxisConfig.textStyle.fontSize + val newFontSize = if (baseFontSize.isSp) { + (baseFontSize.value * scaleFactor).sp + } else baseFontSize + yAxisConfig.copy(textStyle = yAxisConfig.textStyle.copy(fontSize = newFontSize)) + } else { + yAxisConfig + } + + Column(modifier = modifier) { + Row(horizontalArrangement = Arrangement.Start) { + + // Y-axis scale + if (effectiveYAxisConfig.isAxisScaleEnabled) { + LineGraphYAxisScale( + yAxisConfig = effectiveYAxisConfig, + yAxisStepHeight = yAxisStepHeight, + yAxisScaleStep = yAxisScaleStep, + chartHeight = chartHeight, + ) + } + + // Chart content + axes (fills remaining width) + Column(modifier = Modifier.weight(1f)) { + val scrollableContentWidth = if (scrollEnabled && entries.size > 1) { + minPointSpacing * (entries.size - 1) + horizontalDrawPadding * 2 + } else null + + val scrollContentModifier = + if (scrollEnabled && scrollableContentWidth != null) { + Modifier + .horizontalScroll(scrollState) + .width(scrollableContentWidth) + } else { + Modifier.fillMaxWidth() + } + + Column(modifier = scrollContentModifier) { + + // Chart area: grid + canvas + tooltip + custom markers + BoxWithConstraints { + val containerWidth = maxWidth + // grid lines + if (enableGridLines) { + LineGraphYAxisGridLines( + gridLineStyle = gridLineStyle, + yAxisStepHeight = yAxisStepHeight, + ) + } + + // ── Canvas: line + area fill + points ──────── + Canvas( + modifier = Modifier + .padding(top = yAxisStepHeight) + .fillMaxWidth() + .height(chartHeight) + .pointerInput(entries) { + detectTapGestures { tapOffset -> + val canvasWidth = size.width.toFloat() + val canvasHeight = size.height.toFloat() + val pointCount = entries.size + + if (pointCount < 2) return@detectTapGestures + + val horizontalPadding = + with(density) { horizontalDrawPadding.toPx() } + val drawableWidth = + canvasWidth - 2 * horizontalPadding + val stepX = + drawableWidth / (pointCount - 1) + .coerceAtLeast(1) + + val touchRadius = + with(density) { 24.dp.toPx() } + + var closestIndex = -1 + var closestDist = Float.MAX_VALUE + + entries.forEachIndexed { index, entry -> + val px = + horizontalPadding + index * stepX + val py = + canvasHeight - + entry.normalizedY * canvasHeight + val dist = kotlin.math.sqrt( + (tapOffset.x - px) * + (tapOffset.x - px) + + (tapOffset.y - py) * + (tapOffset.y - py) + ) + if (dist < closestDist) { + closestDist = dist + closestIndex = index + } + } + + if (closestDist <= touchRadius && + closestIndex >= 0 + ) { + selectedPointIndex = closestIndex + onPointClicked?.invoke( + entries[closestIndex].label to + entries[closestIndex].value + ) + } else { + selectedPointIndex = -1 + } + } + } + ) { + val canvasWidth = size.width + val canvasHeight = size.height + val pointCount = entries.size + + if (pointCount < 2) return@Canvas + + val horizontalPadding = horizontalDrawPadding.toPx() + val drawableWidth = + canvasWidth - 2 * horizontalPadding + val stepX = + drawableWidth / (pointCount - 1) + .coerceAtLeast(1) + + // ── compute pixel positions ────────────── + val points = + entries.mapIndexed { index, _ -> + val normalizedY = animatedNormalizedYValues[index] + Offset( + x = horizontalPadding + index * stepX, + y = canvasHeight - + normalizedY * canvasHeight + ) + } + + // ── build paths (line + area) ──────────── + val linePath = Path() + val areaPath = Path() + + buildLineAndAreaPaths( + points = points, + linePath = linePath, + areaPath = areaPath, + canvasBottom = canvasHeight, + smoothCurve = lineConfig.smoothCurve, + curvature = lineConfig.curvature, + animationProgress = animationProgress + ) + + // ── draw area fill ─────────────────────── + if (areaFillConfig.enabled) { + drawPath( + path = areaPath, + brush = areaFillConfig.brush, + alpha = animationProgress + ) + } + + // ── draw line ──────────────────────────── + drawPath( + path = linePath, + color = lineConfig.lineColor, + style = Stroke( + width = lineConfig.lineWidth.toPx(), + cap = lineConfig.strokeCap + ), + alpha = animationProgress + ) + + // ── draw data-point dots ───────────────── + if (pointConfig.enabled && customPointMarker == null) { + val visibleCount = + (points.size * animationProgress).toInt() + .coerceIn(0, points.size) + + for (i in 0 until visibleCount) { + val pt = points[i] + + drawCircle( + color = pointConfig.borderColor, + radius = (pointConfig.radius + + pointConfig.borderWidth).toPx(), + center = pt + ) + drawCircle( + color = pointConfig.color, + radius = pointConfig.radius.toPx(), + center = pt + ) + } + } + + // ── highlight selected point ───────────── + if (selectedPointIndex in points.indices) { + val pt = points[selectedPointIndex] + drawCircle( + color = lineConfig.lineColor + .copy(alpha = 0.3f), + radius = (pointConfig.radius + + pointConfig.borderWidth + + 4.dp).toPx(), + center = pt + ) + } + + // ── live-update: blinking last point ───── + if (liveUpdateConfig.enabled && + liveUpdateConfig.blinkEnabled && + points.isNotEmpty() + ) { + val lastPt = points.last() + val pulseColor = (liveUpdateConfig.blinkColor + ?: lineConfig.lineColor).copy(alpha = blinkAlpha) + drawCircle( + color = pulseColor, + radius = blinkRadius, + center = lastPt + ) + } + } + + // ── Custom point markers ────────────────────── + if (customPointMarker != null && pointConfig.enabled) { + val visibleCount = + (entries.size * animationProgress).toInt() + .coerceIn(0, entries.size) + + for (i in 0 until visibleCount) { + val entry = entries[i] + val pointCount = entries.size + val horizontalPaddingPx = with(density) { + horizontalDrawPadding.toPx() + } + val canvasWidthPx = with(density) { + containerWidth.toPx() + } + val drawableWidth = canvasWidthPx - 2 * horizontalPaddingPx + val stepX = drawableWidth / (pointCount - 1).coerceAtLeast(1) + + val xPos = horizontalPaddingPx + i * stepX + val yPos = with(density) { + chartHeight.toPx() - entry.normalizedY * chartHeight.toPx() + } + + Box( + modifier = Modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + val x = (xPos - placeable.width / 2).toInt() + val y = (yPos - placeable.height / 2 + + with(density) { yAxisStepHeight.toPx() }).toInt() + placeable.placeRelative(x, y) + } + } + ) { + customPointMarker( + entry.label, + entry.value, + Offset(xPos, yPos) + ) + } + } + } + + // ── Tooltip popup ───────────────────────────── + if ( + selectedPointIndex in entries.indices && + popUpConfig.enabled + ) { + val entry = entries[selectedPointIndex] + + // Calculate popup offset to align with the selected point + val pCount = entries.size + val hPaddingPx = with(density) { horizontalDrawPadding.toPx() } + val cWidthPx = with(density) { containerWidth.toPx() } + val dWidth = cWidthPx - 2 * hPaddingPx + val sX = dWidth / (pCount - 1).coerceAtLeast(1) + val pointX = hPaddingPx + selectedPointIndex * sX + + // Data point Y in canvas coords, then absolute Y + // from BoxWithConstraints top + val chartHeightPx = with(density) { chartHeight.toPx() } + val yAxisStepPx = with(density) { yAxisStepHeight.toPx() } + val pointYInCanvas = chartHeightPx - entry.normalizedY * chartHeightPx + + if (customPopup != null) { + // Use layout-based positioning (same coordinate + // system as customPointMarker) so the popup + // tracks the data point reliably. + Box( + modifier = Modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + // Centre horizontally on pointX, + // place bottom edge above the point + val x = (pointX - placeable.width / 2) + .toInt() + .coerceIn(0, (cWidthPx - placeable.width).toInt().coerceAtLeast(0)) + val y = (pointYInCanvas - placeable.height + yAxisStepPx) + .toInt() + .coerceAtLeast(0) + placeable.placeRelative(x, y) + } + } + ) { + customPopup( + entry.label, + entry.value + ) { + selectedPointIndex = -1 + } + } + } else { + val rawPopupOffsetX = (pointX - cWidthPx / 2f).toInt() + val estimatedHalfWidthPx = with(density) { 80.dp.toPx() } + val maxAbsOffsetX = ((cWidthPx / 2f) - estimatedHalfWidthPx) + .coerceAtLeast(0f).toInt() + val popupOffsetX = rawPopupOffsetX + .coerceIn(-maxAbsOffsetX, maxAbsOffsetX) + val pointYFromTop = yAxisStepPx + pointYInCanvas + val popupMargin = with(density) { 48.dp.toPx() } + val popupOffsetY = (pointYFromTop - popupMargin).toInt().coerceAtLeast(0) + + LineGraphPopup( + popUpConfig = popUpConfig, + text = "${entry.label}: ${ + formatDecimal(entry.value) + }", + offset = IntOffset(popupOffsetX, popupOffsetY), + onDismissRequest = { + selectedPointIndex = -1 + } + ) + } + } + } + + // ── X-axis line ────────────────────────────────── + if (xAxisConfig.isAxisLineEnabled) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(xAxisConfig.axisLineWidth) + .clip(xAxisConfig.axisLineShape) + .background(xAxisConfig.axisLineColor) + ) + } + + // ── X-axis labels ──────────────────────────────── + if (xAxisConfig.isAxisScaleEnabled) { + Spacer( + modifier = Modifier.height(4.dp) + ) + + val labelTextStyle = + if (autoShrinkXAxisLabels && + entries.size > 8 + ) { + val scaleFactor = + (8f / entries.size).coerceIn(0.5f, 1f) + val baseFontSize = + xAxisConfig.textStyle.fontSize + val newFontSize = + if (baseFontSize.isSp) { + (baseFontSize.value * scaleFactor).sp + } else baseFontSize + xAxisConfig.textStyle.copy( + fontSize = newFontSize + ) + } else { + xAxisConfig.textStyle + } + + Box( + modifier = Modifier.fillMaxWidth() + ) { + entries.forEachIndexed { index, entry -> + val displayText = + if (entry.label.length > + maxTextLengthXAxis + ) { + "${ + entry.label.take( + if (maxTextLengthXAxis > 5) + (maxTextLengthXAxis - 2) + else maxTextLengthXAxis + ) + }.." + } else { + entry.label + } + + val labelModifier = + if (enableTextRotate) { + Modifier + .graphicsLayer( + rotationZ = textRotateAngle + ) + .clickable { + onPointClicked?.invoke( + entry.label to + entry.value + ) + } + .padding(horizontal = 2.dp) + } else { + Modifier.clickable { + onPointClicked?.invoke( + entry.label to entry.value + ) + } + } + + // Position label using same calculation as line points + val pointCount = entries.size + val fraction = if (pointCount > 1) { + index.toFloat() / (pointCount - 1) + } else 0.5f + + Text( + text = displayText, + style = labelTextStyle, + modifier = labelModifier + .align(Alignment.TopStart) + .layout { measurable, constraints -> + val placeable = + measurable.measure(constraints) + layout(placeable.width, placeable.height) { + val horizontalPaddingPx = + horizontalDrawPadding.toPx() + val availableWidth = + constraints.maxWidth.toFloat() + val drawableWidth = + availableWidth - 2 * horizontalPaddingPx + val xPos = + horizontalPaddingPx + + fraction * drawableWidth - + placeable.width / 2 + placeable.placeRelative( + x = xPos.toInt(), + y = 0 + ) + } + }, + maxLines = 1, + ) + } + } + } + } + } + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ Private helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +// ━━━━━━━━━━━━━━━━━━━━━ Formatting helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/** + * Formats a decimal number to at most 2 decimal places, removing trailing zeros. + * Pure Kotlin implementation for Multiplatform compatibility. + */ +private fun formatDecimal(value: Float): String { + if (value.isNaN() || value.isInfinite()) return "0" + val negative = value < 0f + val absRounded = round(abs(value) * 100).toLong() + val intPart = absRounded / 100 + val decPart = (absRounded % 100).toInt() + val prefix = if (negative && (intPart > 0 || decPart > 0)) "-" else "" + return when { + decPart == 0 -> "$prefix$intPart" + decPart % 10 == 0 -> "$prefix$intPart.${decPart / 10}" + else -> "$prefix$intPart.${decPart.toString().padStart(2, '0')}" + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ Private helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/** + * Constructs a line path and a closed area path from [points]. + * + * When [smoothCurve] is `true` the points are connected via cubic + * Bézier curves whose strength is controlled by [curvature] + * (mirroring Vico's `CubicPointConnector` algorithm). Otherwise + * straight segments are used. + * + * The [animationProgress] (`0f..1f`) controls how much of the + * path is drawn — `0` means nothing is visible, `1` means the + * entire line is drawn. + */ +private fun buildLineAndAreaPaths( + points: List, + linePath: Path, + areaPath: Path, + canvasBottom: Float, + smoothCurve: Boolean, + curvature: Float, + animationProgress: Float, +) { + if (points.isEmpty()) return + + // Determine how many segments to draw based on animation progress. + // Fractional parts are interpolated so the line grows smoothly. + val totalSegments = points.size - 1 + val progressSegments = animationProgress * totalSegments + val fullSegments = progressSegments.toInt().coerceIn(0, totalSegments) + val partialFraction = progressSegments - fullSegments + + linePath.moveTo(points[0].x, points[0].y) + areaPath.moveTo(points[0].x, canvasBottom) // start at bottom-left of first point X + areaPath.lineTo(points[0].x, points[0].y) + + for (i in 0 until fullSegments) { + connectPoints( + path = linePath, + areaPath = areaPath, + from = points[i], + to = points[i + 1], + smoothCurve = smoothCurve, + curvature = curvature, + fraction = 1f + ) + } + + // partial last segment (animated) + if (fullSegments < totalSegments && partialFraction > 0f) { + connectPoints( + path = linePath, + areaPath = areaPath, + from = points[fullSegments], + to = points[fullSegments + 1], + smoothCurve = smoothCurve, + curvature = curvature, + fraction = partialFraction + ) + } + + // Close the area path at the bottom + // Interpolate the endpoint X position for the partial segment + val endX = if (partialFraction > 0f && fullSegments < totalSegments) { + lerp(points[fullSegments].x, points[fullSegments + 1].x, partialFraction) + } else { + points[fullSegments.coerceIn(points.indices)].x + } + + areaPath.lineTo(endX, canvasBottom) + areaPath.close() +} + +/** + * Connects two consecutive points in the line path, optionally + * also extending the area path. + * + * For smooth curves the control-point placement mirrors Vico's + * `CubicPointConnector`: + * + * ``` + * cp1 = (from.x + dx * curvature, from.y) + * cp2 = (to.x - dx * curvature, to.y ) + * ``` + * + * where `dx = |to.x - from.x|`. This produces aesthetically + * pleasing horizontal-tension curves. + * + * The [fraction] parameter (`0f..1f`) allows drawing only a + * portion of the segment, used for the entrance animation. + */ +private fun connectPoints( + path: Path, + areaPath: Path, + from: Offset, + to: Offset, + smoothCurve: Boolean, + curvature: Float, + fraction: Float, +) { + // interpolated endpoint + val targetX = lerp(from.x, to.x, fraction) + val targetY = lerp(from.y, to.y, fraction) + + if (smoothCurve) { + val dx = abs(to.x - from.x) + + // Control points: horizontal-tension Bézier + val cp1x = from.x + dx * curvature + val cp1y = from.y + val cp2x = to.x - dx * curvature + val cp2y = to.y + + // Interpolate control points along fraction + val iCp1x = lerp(from.x, cp1x, fraction) + val iCp1y = lerp(from.y, cp1y, fraction) + val iCp2x = lerp(from.x, cp2x, fraction) + val iCp2y = lerp(from.y, cp2y, fraction) + + path.cubicTo(iCp1x, iCp1y, iCp2x, iCp2y, targetX, targetY) + areaPath.cubicTo(iCp1x, iCp1y, iCp2x, iCp2y, targetX, targetY) + } else { + path.lineTo(targetX, targetY) + areaPath.lineTo(targetX, targetY) + } +} + +/** Simple linear interpolation between [a] and [b] at [t]. */ +private fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/MultiLineGraph.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/MultiLineGraph.kt new file mode 100644 index 0000000..563b67e --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/MultiLineGraph.kt @@ -0,0 +1,919 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.linegraph + +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.developerstring.jetco_kmp.charts.linegraph.components.LineGraphPopup +import com.developerstring.jetco_kmp.charts.linegraph.components.LineGraphYAxisGridLines +import com.developerstring.jetco_kmp.charts.linegraph.components.LineGraphYAxisScale +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphAnimationConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphDefaults +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphGridLineStyle +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphLineConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphLiveUpdateConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphPointConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphPopUpConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphXAxisConfig +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphYAxisConfig +import com.developerstring.jetco_kmp.charts.linegraph.model.LineGraphSeries +import kotlin.math.abs +import kotlin.math.round + +/** + * A multi-series animated line graph composable for Jetpack Compose. + * + * Draws multiple data series on the same Cartesian plane, each with + * its own color. Area fill gradients are automatically derived from + * the series color. Supports smooth cubic Bézier curves, data-point + * dots, grid lines, axes, tooltips, and an optional legend. + * + * ## Quick start + * ```kotlin + * MultiLineGraph( + * labels = listOf("Jan", "Feb", "Mar", "Apr"), + * seriesList = listOf( + * LineGraphSeries("Revenue", listOf(100f, 200f, 150f, 300f), Color.Blue), + * LineGraphSeries("Expense", listOf(80f, 120f, 90f, 200f), Color.Red), + * ), + * ) + * ``` + * + * @param modifier Modifier applied to the outermost container. + * @param labels Ordered X-axis labels. Every series must have + * the same number of values as [labels]. + * @param seriesList List of [LineGraphSeries] to render. + * @param chartHeight Height of the drawing area (excludes axes). + * @param lineConfig Shared line styling (width, cap, curve mode). + * The **colour is overridden** per series. + * @param pointConfig Shared dot styling. The **border colour is + * overridden** per series. + * @param enableAreaFill Whether a gradient area fill is drawn below + * each line. + * @param yAxisConfig Y-axis label configuration. + * @param xAxisConfig X-axis label configuration. + * @param gridLineStyle Horizontal grid lines. + * @param popUpConfig Tooltip configuration. + * @param animationConfig Entrance animation timing. + * @param maxValue Optional Y-axis ceiling. Defaults to the global + * maximum across all series. + * @param enableGridLines Whether to draw grid lines. + * @param enableLegend Whether to render a colour-coded legend below + * the chart. + * @param maxTextLengthXAxis Labels longer than this are truncated. + * @param horizontalDrawPadding Horizontal space between the Y-axis + * and the first / last data point. + * Defaults to `0.dp` (line starts right + * at the axis). + * @param scrollEnabled When `true` the chart area becomes horizontally + * scrollable — useful for large data sets. + * @param minPointSpacing Minimum horizontal spacing between data points + * when [scrollEnabled] is `true`. + * @param autoShrinkXAxisLabels When `true`, X-axis label font size is + * automatically reduced to fit when many + * labels are present. + * @param autoShrinkYAxisLabels When `true`, Y-axis label font size is + * automatically reduced when there are many + * scale steps. + * @param customPointMarker Optional custom composable for rendering data point markers. + * Receives series name, label, value, and offset position. + * When `null`, default circle markers are drawn. + * @param customPopup Optional custom composable for the tooltip popup. + * Receives series name, label, value, and dismiss callback. + * When `null`, default popup is shown. + * @param onPointClicked Callback receiving (seriesName, label, value) + * when a data point is tapped. + * + * @see LineGraphDefaults + * @see LineGraphSeries + */ +@Composable +fun MultiLineGraph( + modifier: Modifier = Modifier, + labels: List, + seriesList: List, + chartHeight: Dp = 200.dp, + lineConfig: LineGraphLineConfig = LineGraphDefaults.lineConfig(), + pointConfig: LineGraphPointConfig = LineGraphDefaults.pointConfig(), + enableAreaFill: Boolean = true, + yAxisConfig: LineGraphYAxisConfig = LineGraphDefaults.yAxisConfig(), + xAxisConfig: LineGraphXAxisConfig = LineGraphDefaults.xAxisConfig(), + gridLineStyle: LineGraphGridLineStyle = LineGraphDefaults.gridLineStyle(), + popUpConfig: LineGraphPopUpConfig = LineGraphDefaults.popUpConfig(), + animationConfig: LineGraphAnimationConfig = LineGraphDefaults.animationConfig(), + liveUpdateConfig: LineGraphLiveUpdateConfig = LineGraphDefaults.liveUpdateConfig(), + maxValue: Float? = null, + enableGridLines: Boolean = true, + enableLegend: Boolean = true, + maxTextLengthXAxis: Int = 6, + horizontalDrawPadding: Dp = 0.dp, + scrollEnabled: Boolean = false, + minPointSpacing: Dp = 48.dp, + autoShrinkXAxisLabels: Boolean = false, + autoShrinkYAxisLabels: Boolean = false, + customPointMarker: (@Composable (seriesName: String, label: String, value: Float, offset: Offset, color: Color) -> Unit)? = null, + customPopup: (@Composable (seriesName: String, label: String, value: Float, onDismiss: () -> Unit) -> Unit)? = null, + onPointClicked: ((seriesName: String, label: String, value: Float) -> Unit)? = null, +) { + if (seriesList.isEmpty() || labels.isEmpty()) return + + // ── data preparation ───────────────────────────────────────────── + val globalMax = maxValue + ?: seriesList.maxOfOrNull { it.data.maxOrNull() ?: 0f } + ?: 0f + + // Normalize every series independently using the shared globalMax + data class NormalizedPoint(val label: String, val value: Float, val normalizedY: Float) + + val normalizedSeries: List> = seriesList.map { series -> + series.data.mapIndexed { index, value -> + val safeValue = if (value.isNaN() || value.isInfinite()) 0f else value + val safeMax = if (globalMax.isNaN() || globalMax.isInfinite() || globalMax == 0f) 1f else globalMax + NormalizedPoint( + label = labels.getOrElse(index) { "" }, + value = safeValue, + normalizedY = (safeValue / safeMax).coerceIn(0f, 1f) + ) + } + } + + // ── animation ──────────────────────────────────────────────────── + var animationPlayed by remember { mutableStateOf(!animationConfig.enabled) } + + val animationProgress by animateFloatAsState( + targetValue = if (animationPlayed) 1f else 0f, + animationSpec = tween( + durationMillis = animationConfig.durationMillis, + delayMillis = animationConfig.delayMillis, + easing = LinearOutSlowInEasing + ), + label = "multiLineGraphAnimation" + ) + + if (animationConfig.enabled) { + LaunchedEffect(Unit) { animationPlayed = true } + } + + // ── live-update: animated normalizedY per series ────────────────── + val animatedNormalizedSeries: List> = normalizedSeries.map { series -> + series.map { pt -> + if (liveUpdateConfig.enabled) { + animateFloatAsState( + targetValue = pt.normalizedY, + animationSpec = tween( + durationMillis = liveUpdateConfig.pathTransitionDurationMillis, + easing = LinearOutSlowInEasing + ), + label = "liveMultiY" + ).value + } else { + pt.normalizedY + } + } + } + + // ── live-update: blink animation ───────────────────────────────── + val blinkRadius = if (liveUpdateConfig.enabled && liveUpdateConfig.blinkEnabled) { + val infiniteTransition = rememberInfiniteTransition(label = "multiBlinkTransition") + infiniteTransition.animateFloat( + initialValue = liveUpdateConfig.blinkMinRadius, + targetValue = liveUpdateConfig.blinkMaxRadius, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = liveUpdateConfig.blinkDurationMillis, + easing = LinearOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "multiBlinkRadius" + ).value + } else { + 0f + } + + val blinkAlpha = if (liveUpdateConfig.enabled && liveUpdateConfig.blinkEnabled) { + val infiniteTransition = rememberInfiniteTransition(label = "multiBlinkAlphaTransition") + infiniteTransition.animateFloat( + initialValue = 0.8f, + targetValue = 0.2f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = liveUpdateConfig.blinkDurationMillis, + easing = LinearOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "multiBlinkAlpha" + ).value + } else { + 0f + } + + // ── axis maths ─────────────────────────────────────────────────── + val yAxisScaleStep = if (yAxisConfig.axisScaleCount > 0) { + globalMax / yAxisConfig.axisScaleCount + } else globalMax + + val yAxisStepHeight = if (yAxisConfig.axisScaleCount > 0) { + chartHeight / yAxisConfig.axisScaleCount + } else chartHeight + + // ── tooltip state ──────────────────────────────────────────────── + var selectedSeriesIndex by remember { mutableIntStateOf(-1) } + var selectedPointIndex by remember { mutableIntStateOf(-1) } + + val density = LocalDensity.current + + // horizontal scroll state + val scrollState = rememberScrollState() + + // ── Y-axis auto-shrink ────────────────────────────────────────── + val effectiveYAxisConfig = if (autoShrinkYAxisLabels && yAxisConfig.axisScaleCount > 6) { + val scaleFactor = (6f / yAxisConfig.axisScaleCount).coerceIn(0.5f, 1f) + val baseFontSize = yAxisConfig.textStyle.fontSize + val newFontSize = if (baseFontSize.isSp) { + (baseFontSize.value * scaleFactor).sp + } else baseFontSize + yAxisConfig.copy(textStyle = yAxisConfig.textStyle.copy(fontSize = newFontSize)) + } else { + yAxisConfig + } + + Column(modifier = modifier) { + Row(horizontalArrangement = Arrangement.Start) { + + if (effectiveYAxisConfig.isAxisScaleEnabled) { + LineGraphYAxisScale( + yAxisConfig = effectiveYAxisConfig, + yAxisStepHeight = yAxisStepHeight, + yAxisScaleStep = yAxisScaleStep, + chartHeight = chartHeight, + ) + } + + // Chart content + axes (fills remaining width) + Column(modifier = Modifier.weight(1f)) { + val scrollableContentWidth = + if (scrollEnabled && labels.size > 1) { + minPointSpacing * (labels.size - 1) + + horizontalDrawPadding * 2 + } else null + + val scrollContentModifier = + if (scrollEnabled && + scrollableContentWidth != null + ) { + Modifier + .horizontalScroll(scrollState) + .width(scrollableContentWidth) + } else { + Modifier.fillMaxWidth() + } + + Column(modifier = scrollContentModifier) { + + // Chart area: grid + canvas + tooltip + custom markers + BoxWithConstraints { + val containerWidth = maxWidth + if (enableGridLines) { + LineGraphYAxisGridLines( + gridLineStyle = gridLineStyle, + yAxisStepHeight = yAxisStepHeight, + ) + } + + Canvas( + modifier = Modifier + .padding(top = yAxisStepHeight) + .fillMaxWidth() + .height(chartHeight) + .pointerInput(normalizedSeries) { + detectTapGestures { tapOffset -> + val canvasWidth = + size.width.toFloat() + val canvasHeight = + size.height.toFloat() + val pointCount = labels.size + if (pointCount < 2) + return@detectTapGestures + + val horizontalPadding = + with(density) { + horizontalDrawPadding.toPx() + } + val drawableWidth = + canvasWidth - + 2 * horizontalPadding + val stepX = + drawableWidth / + (pointCount - 1) + .coerceAtLeast(1) + val touchRadius = + with(density) { 24.dp.toPx() } + + var bestSeries = -1 + var bestPoint = -1 + var bestDist = Float.MAX_VALUE + + normalizedSeries.forEachIndexed { + sIdx, series -> + series.forEachIndexed { + pIdx, pt -> + val px = + horizontalPadding + + pIdx * stepX + val py = + canvasHeight - + pt.normalizedY * + canvasHeight + val dist = + kotlin.math.sqrt( + (tapOffset.x - px) * + (tapOffset.x - px) + + (tapOffset.y - py) * + (tapOffset.y - py) + ) + if (dist < bestDist) { + bestDist = dist + bestSeries = sIdx + bestPoint = pIdx + } + } + } + + if (bestDist <= touchRadius && + bestSeries >= 0 + ) { + selectedSeriesIndex = bestSeries + selectedPointIndex = bestPoint + val pt = normalizedSeries[bestSeries][bestPoint] + onPointClicked?.invoke( + seriesList[bestSeries].name, + pt.label, + pt.value + ) + } else { + selectedSeriesIndex = -1 + selectedPointIndex = -1 + } + } + } + ) { + val canvasWidth = size.width + val canvasHeight = size.height + val pointCount = labels.size + + if (pointCount < 2) return@Canvas + + val horizontalPadding = + horizontalDrawPadding.toPx() + val drawableWidth = + canvasWidth - 2 * horizontalPadding + val stepX = + drawableWidth / (pointCount - 1) + .coerceAtLeast(1) + + normalizedSeries.forEachIndexed { + seriesIndex, series -> + val seriesColor = + seriesList[seriesIndex].color + + val points = + series.mapIndexed { index, pt -> + val animY = animatedNormalizedSeries[seriesIndex][index] + Offset( + x = horizontalPadding + + index * stepX, + y = canvasHeight - + animY * + canvasHeight + ) + } + + val linePath = Path() + val areaPath = Path() + + buildMultiLinePaths( + points = points, + linePath = linePath, + areaPath = areaPath, + canvasBottom = canvasHeight, + smoothCurve = lineConfig.smoothCurve, + curvature = lineConfig.curvature, + animationProgress = animationProgress + ) + + // Area fill + if (enableAreaFill) { + drawPath( + path = areaPath, + brush = Brush.verticalGradient( + listOf( + seriesColor.copy( + alpha = 0.3f + ), + Color.Transparent + ) + ), + alpha = animationProgress + ) + } + + // Line + drawPath( + path = linePath, + color = seriesColor, + style = Stroke( + width = + lineConfig.lineWidth.toPx(), + cap = lineConfig.strokeCap + ), + alpha = animationProgress + ) + + // Dots + if (pointConfig.enabled && customPointMarker == null) { + val visibleCount = + (points.size * + animationProgress) + .toInt() + .coerceIn(0, points.size) + + for (i in 0 until visibleCount) { + val pt = points[i] + drawCircle( + color = seriesColor, + radius = (pointConfig.radius + + pointConfig.borderWidth) + .toPx(), + center = pt + ) + drawCircle( + color = pointConfig.color, + radius = + pointConfig.radius.toPx(), + center = pt + ) + } + } + + // Selection highlight + if (selectedSeriesIndex == seriesIndex && + selectedPointIndex in points.indices + ) { + val pt = points[selectedPointIndex] + drawCircle( + color = seriesColor.copy( + alpha = 0.3f + ), + radius = (pointConfig.radius + + pointConfig.borderWidth + + 4.dp).toPx(), + center = pt + ) + } + + // ── live-update: blinking last point ─ + if (liveUpdateConfig.enabled && + liveUpdateConfig.blinkEnabled && + points.isNotEmpty() + ) { + val lastPt = points.last() + val pulseColor = (liveUpdateConfig.blinkColor + ?: seriesColor).copy(alpha = blinkAlpha) + drawCircle( + color = pulseColor, + radius = blinkRadius, + center = lastPt + ) + } + } + } + + // Custom point markers + if (customPointMarker != null && pointConfig.enabled) { + + normalizedSeries.forEachIndexed { seriesIndex, series -> + val visibleCount = + (series.size * animationProgress).toInt() + .coerceIn(0, series.size) + + for (i in 0 until visibleCount) { + val pt = series[i] + val pointCount = labels.size + val horizontalPaddingPx = with(density) { + horizontalDrawPadding.toPx() + } + val canvasWidthPx = with(density) { + containerWidth.toPx() + } + val drawableWidth = canvasWidthPx - 2 * horizontalPaddingPx + val stepX = drawableWidth / (pointCount - 1).coerceAtLeast(1) + + val xPos = horizontalPaddingPx + i * stepX + val yPos = with(density) { + chartHeight.toPx() - pt.normalizedY * chartHeight.toPx() + } + + Box( + modifier = Modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + val x = (xPos - placeable.width / 2).toInt() + val y = (yPos - placeable.height / 2 + + with(density) { yAxisStepHeight.toPx() }).toInt() + placeable.placeRelative(x, y) + } + } + ) { + customPointMarker( + seriesList[seriesIndex].name, + pt.label, + pt.value, + Offset(xPos, yPos), + seriesList[seriesIndex].color + ) + } + } + } + } + + // Tooltip + if ( + selectedSeriesIndex in seriesList.indices && + selectedPointIndex in + normalizedSeries[selectedSeriesIndex].indices && + popUpConfig.enabled + ) { + val pt = normalizedSeries[selectedSeriesIndex][selectedPointIndex] + + // Calculate popup offset to align with the selected point + val pCount = labels.size + val hPaddingPx = with(density) { horizontalDrawPadding.toPx() } + val cWidthPx = with(density) { containerWidth.toPx() } + val dWidth = cWidthPx - 2 * hPaddingPx + val sX = dWidth / (pCount - 1).coerceAtLeast(1) + val pointX = hPaddingPx + selectedPointIndex * sX + + // Data point Y in canvas coords, then absolute Y + // from BoxWithConstraints top + val chartHeightPx = with(density) { chartHeight.toPx() } + val yAxisStepPx = with(density) { yAxisStepHeight.toPx() } + val pointYInCanvas = chartHeightPx - pt.normalizedY * chartHeightPx + + if (customPopup != null) { + // Use layout-based positioning (same coordinate + // system as customPointMarker) so the popup + // tracks the data point reliably. + Box( + modifier = Modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + val x = (pointX - placeable.width / 2) + .toInt() + .coerceIn(0, (cWidthPx - placeable.width).toInt().coerceAtLeast(0)) + val y = (pointYInCanvas - placeable.height + yAxisStepPx) + .toInt() + .coerceAtLeast(0) + placeable.placeRelative(x, y) + } + } + ) { + customPopup( + seriesList[selectedSeriesIndex].name, + pt.label, + pt.value + ) { + selectedSeriesIndex = -1 + selectedPointIndex = -1 + } + } + } else { + val rawPopupOffsetX = (pointX - cWidthPx / 2f).toInt() + val estimatedHalfWidthPx = with(density) { 80.dp.toPx() } + val maxAbsOffsetX = ((cWidthPx / 2f) - estimatedHalfWidthPx) + .coerceAtLeast(0f).toInt() + val popupOffsetX = rawPopupOffsetX + .coerceIn(-maxAbsOffsetX, maxAbsOffsetX) + val pointYFromTop = yAxisStepPx + pointYInCanvas + val popupMargin = with(density) { 48.dp.toPx() } + val popupOffsetY = (pointYFromTop - popupMargin).toInt().coerceAtLeast(0) + + LineGraphPopup( + popUpConfig = popUpConfig, + text = "${ + seriesList[selectedSeriesIndex].name + }: ${formatDecimal(pt.value)}", + offset = IntOffset(popupOffsetX, popupOffsetY), + onDismissRequest = { + selectedSeriesIndex = -1 + selectedPointIndex = -1 + } + ) + } + } + } + + // ── X-axis line ────────────────────────────── + if (xAxisConfig.isAxisLineEnabled) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(xAxisConfig.axisLineWidth) + .clip(xAxisConfig.axisLineShape) + .background(xAxisConfig.axisLineColor) + ) + } + + // ── X-axis labels ──────────────────────────── + if (xAxisConfig.isAxisScaleEnabled) { + Spacer( + modifier = Modifier.height(4.dp) + ) + + val labelTextStyle = + if (autoShrinkXAxisLabels && + labels.size > 8 + ) { + val scaleFactor = + (8f / labels.size) + .coerceIn(0.5f, 1f) + val baseFontSize = + xAxisConfig.textStyle.fontSize + val newFontSize = + if (baseFontSize.isSp) { + (baseFontSize.value * + scaleFactor).sp + } else baseFontSize + xAxisConfig.textStyle.copy( + fontSize = newFontSize + ) + } else { + xAxisConfig.textStyle + } + + Box( + modifier = Modifier.fillMaxWidth() + ) { + labels.forEachIndexed { index, label -> + val displayText = + if (label.length > + maxTextLengthXAxis + ) { + "${ + label.take( + if (maxTextLengthXAxis > 5) + (maxTextLengthXAxis - 2) + else maxTextLengthXAxis + ) + }.." + } else label + + // Position label using same calculation as line points + val pointCount = labels.size + val fraction = if (pointCount > 1) { + index.toFloat() / (pointCount - 1) + } else 0.5f + + Text( + text = displayText, + style = labelTextStyle, + modifier = Modifier + .align(Alignment.TopStart) + .layout { measurable, constraints -> + val placeable = + measurable.measure(constraints) + layout(placeable.width, placeable.height) { + val horizontalPaddingPx = + horizontalDrawPadding.toPx() + val availableWidth = + constraints.maxWidth.toFloat() + val drawableWidth = + availableWidth - 2 * horizontalPaddingPx + val xPos = + horizontalPaddingPx + + fraction * drawableWidth - + placeable.width / 2 + placeable.placeRelative( + x = xPos.toInt(), + y = 0 + ) + } + }, + maxLines = 1, + ) + } + } + } + } + } + } + + // Legend + if (enableLegend) { + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + seriesList.forEach { series -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(series.color) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = series.name, + style = xAxisConfig.textStyle + ) + } + } + } + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ Private helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +// ━━━━━━━━━━━━━━━━━━━━━ Formatting helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/** + * Formats a decimal number to at most 2 decimal places, removing trailing zeros. + * Pure Kotlin implementation for Multiplatform compatibility. + */ +private fun formatDecimal(value: Float): String { + if (value.isNaN() || value.isInfinite()) return "0" + val negative = value < 0f + val absRounded = round(abs(value) * 100).toLong() + val intPart = absRounded / 100 + val decPart = (absRounded % 100).toInt() + val prefix = if (negative && (intPart > 0 || decPart > 0)) "-" else "" + return when { + decPart == 0 -> "$prefix$intPart" + decPart % 10 == 0 -> "$prefix$intPart.${decPart / 10}" + else -> "$prefix$intPart.${decPart.toString().padStart(2, '0')}" + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ Private helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/** + * Builds line and area paths for a single series in the multi-line + * graph. The algorithm mirrors [buildLineAndAreaPaths] in the + * single-series [LineGraph]. + */ +private fun buildMultiLinePaths( + points: List, + linePath: Path, + areaPath: Path, + canvasBottom: Float, + smoothCurve: Boolean, + curvature: Float, + animationProgress: Float, +) { + if (points.isEmpty()) return + + val totalSegments = points.size - 1 + val progressSegments = animationProgress * totalSegments + val fullSegments = progressSegments.toInt().coerceIn(0, totalSegments) + val partialFraction = progressSegments - fullSegments + + linePath.moveTo(points[0].x, points[0].y) + areaPath.moveTo(points[0].x, canvasBottom) + areaPath.lineTo(points[0].x, points[0].y) + + for (i in 0 until fullSegments) { + multiConnectPoints( + path = linePath, + areaPath = areaPath, + from = points[i], + to = points[i + 1], + smoothCurve = smoothCurve, + curvature = curvature, + fraction = 1f + ) + } + + if (fullSegments < totalSegments && partialFraction > 0f) { + multiConnectPoints( + path = linePath, + areaPath = areaPath, + from = points[fullSegments], + to = points[fullSegments + 1], + smoothCurve = smoothCurve, + curvature = curvature, + fraction = partialFraction + ) + } + + val endX = if (partialFraction > 0f && fullSegments < totalSegments) { + multiLerp(points[fullSegments].x, points[fullSegments + 1].x, partialFraction) + } else { + points[fullSegments.coerceIn(points.indices)].x + } + + areaPath.lineTo(endX, canvasBottom) + areaPath.close() +} + +private fun multiConnectPoints( + path: Path, + areaPath: Path, + from: Offset, + to: Offset, + smoothCurve: Boolean, + curvature: Float, + fraction: Float, +) { + val targetX = multiLerp(from.x, to.x, fraction) + val targetY = multiLerp(from.y, to.y, fraction) + + if (smoothCurve) { + val dx = abs(to.x - from.x) + val cp1x = from.x + dx * curvature + val cp1y = from.y + val cp2x = to.x - dx * curvature + val cp2y = to.y + + val iCp1x = multiLerp(from.x, cp1x, fraction) + val iCp1y = multiLerp(from.y, cp1y, fraction) + val iCp2x = multiLerp(from.x, cp2x, fraction) + val iCp2y = multiLerp(from.y, cp2y, fraction) + + path.cubicTo(iCp1x, iCp1y, iCp2x, iCp2y, targetX, targetY) + areaPath.cubicTo(iCp1x, iCp1y, iCp2x, iCp2y, targetX, targetY) + } else { + path.lineTo(targetX, targetY) + areaPath.lineTo(targetX, targetY) + } +} + +private fun multiLerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/components/LineGraphGridLine.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/components/LineGraphGridLine.kt new file mode 100644 index 0000000..689e607 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/components/LineGraphGridLine.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.linegraph.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.PathEffect +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphGridLineStyle + +/** + * Draws a single horizontal dashed grid line. + * + * This composable is designed to be placed inside a repeating layout + * so that multiple grid lines can be stacked vertically across the + * chart area. + * + * @param modifier Modifier applied to the underlying [Canvas]. + * @param gridLineStyle Visual configuration for the grid line (color, + * stroke width, dash pattern, cap). + * + * @see LineGraphGridLineStyle + */ +@Composable +internal fun LineGraphGridLine( + modifier: Modifier = Modifier, + gridLineStyle: LineGraphGridLineStyle, +) { + Canvas(modifier = modifier.padding(top = gridLineStyle.strokeWidth / 2)) { + + val dashLengthPx = gridLineStyle.dashLength.toPx() + val gapLengthPx = gridLineStyle.gapLength.toPx() + val strokeWidthPx = gridLineStyle.strokeWidth.toPx() + + // Skia's MakeDash requires all intervals > 0; passing 0 returns + // nullptr which crashes on Kotlin/Native ("Can't wrap nullptr"). + val pathEffect = if (dashLengthPx > 0f && gapLengthPx > 0f) { + PathEffect.dashPathEffect( + floatArrayOf(dashLengthPx, gapLengthPx), 0f + ) + } else null + + drawLine( + color = gridLineStyle.color, + start = Offset(0f, size.height / 2), + end = Offset(size.width, size.height / 2), + strokeWidth = strokeWidthPx, + pathEffect = pathEffect, + cap = gridLineStyle.dashCap + ) + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/components/LineGraphPopup.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/components/LineGraphPopup.kt new file mode 100644 index 0000000..2568588 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/components/LineGraphPopup.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.linegraph.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphPopUpConfig + +/** + * Displays a floating popup tooltip above a data point. + * + * The popup is dismissed automatically when the user touches + * outside, or the caller can trigger [onDismissRequest]. + * + * @param popUpConfig Visual configuration for the tooltip. + * @param text Content text to show inside the tooltip. + * @param onDismissRequest Callback invoked when the popup should + * be closed. + * + * @see LineGraphPopUpConfig + */ +@Composable +internal fun LineGraphPopup( + popUpConfig: LineGraphPopUpConfig, + text: String, + offset: IntOffset = IntOffset.Zero, + onDismissRequest: () -> Unit +) { + Popup( + alignment = Alignment.TopCenter, + offset = offset, + onDismissRequest = { onDismissRequest() }, + ) { + Box( + modifier = Modifier + .background( + color = popUpConfig.background, + shape = popUpConfig.shape + ) + .padding(8.dp) + ) { + Text( + text = text, + style = popUpConfig.textStyle + ) + } + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/components/LineGraphYAxisGridLines.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/components/LineGraphYAxisGridLines.kt new file mode 100644 index 0000000..ee34384 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/components/LineGraphYAxisGridLines.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.linegraph.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphGridLineStyle + +/** + * Renders a stack of horizontal dashed grid lines that span the + * chart width. + * + * The composable positions [LineGraphGridLine]s at equal vertical + * intervals determined by [yAxisStepHeight]. + * + * @param gridLineStyle Visual configuration for each grid line. + * @param yAxisStepHeight Vertical distance between consecutive grid lines. + * + * @see LineGraphGridLine + * @see LineGraphGridLineStyle + */ +@Composable +internal fun LineGraphYAxisGridLines( + gridLineStyle: LineGraphGridLineStyle, + yAxisStepHeight: Dp +) { + Column( + modifier = Modifier + .padding(top = yAxisStepHeight) + .fillMaxWidth(), + ) { + repeat(gridLineStyle.totalGridLines + 1) { index -> + if (index != 0) { + Row( + modifier = Modifier.height(height = yAxisStepHeight), + verticalAlignment = Alignment.Top + ) { + LineGraphGridLine( + modifier = Modifier.fillMaxWidth(), + gridLineStyle = gridLineStyle, + ) + } + } + } + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/components/LineGraphYAxisScale.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/components/LineGraphYAxisScale.kt new file mode 100644 index 0000000..124ef6b --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/components/LineGraphYAxisScale.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.linegraph.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.charts.linegraph.config.LineGraphYAxisConfig +import kotlin.math.abs +import kotlin.math.round + +/** + * Renders the Y-axis scale labels and optional vertical axis line + * for the line graph. + * + * Labels are evenly distributed from `0` to the chart's maximum + * value using [yAxisScaleStep] as the increment. + * + * @param yAxisConfig Y-axis visual configuration. + * @param yAxisStepHeight Height of each scale step (controls label spacing). + * @param yAxisScaleStep Numeric increment between labels. + * @param chartHeight Total height of the charting area. + * + * @see LineGraphYAxisConfig + */ +@Composable +internal fun LineGraphYAxisScale( + yAxisConfig: LineGraphYAxisConfig, + yAxisStepHeight: Dp, + yAxisScaleStep: Float, + chartHeight: Dp, +) { + Row { + Column(horizontalAlignment = Alignment.End) { + repeat(yAxisConfig.axisScaleCount + 1) { index -> + val barScale = (yAxisConfig.axisScaleCount) - index + Row( + modifier = Modifier.height(height = yAxisStepHeight), + verticalAlignment = Alignment.Bottom + ) { + Text( + text = yAxisConfig.textPrefix + + formatLineGraphScaleValue(yAxisScaleStep * barScale) + + yAxisConfig.textPostfix, + style = yAxisConfig.textStyle + ) + } + } + } + + if (yAxisConfig.isAxisLineEnabled) { + Spacer(modifier = Modifier.width(10.dp)) + + Box( + modifier = Modifier + .padding(top = yAxisStepHeight) + .clip(shape = yAxisConfig.axisLineShape) + .width(yAxisConfig.axisLineWidth) + .height(chartHeight) + .background(yAxisConfig.axisLineColor) + ) + } + } +} + +/** + * Formats a decimal number to at most 2 decimal places, removing trailing zeros. + * Pure Kotlin implementation for Multiplatform compatibility. + */ +private fun formatDecimal(value: Float): String { + if (value.isNaN() || value.isInfinite()) return "0" + val negative = value < 0f + val absRounded = round(abs(value) * 100).toLong() + val intPart = absRounded / 100 + val decPart = (absRounded % 100).toInt() + val prefix = if (negative && (intPart > 0 || decPart > 0)) "-" else "" + return when { + decPart == 0 -> "$prefix$intPart" + decPart % 10 == 0 -> "$prefix$intPart.${decPart / 10}" + else -> "$prefix$intPart.${decPart.toString().padStart(2, '0')}" + } +} + +/** + * Formats a numeric value for the Y-axis scale, abbreviating large + * numbers with K (thousands) and M (millions) suffixes. + * + * Values below 10 000 are displayed as-is with up to two decimal places. + * + * @param value The raw numeric value. + * @return A human-readable string representation. + */ +internal fun formatLineGraphScaleValue(value: Float): String { + if (value < 10000) { + return formatDecimal(value) + } + + val am: Float + if (abs(value / 1_000_000) >= 1) { + am = value / 1_000_000 + return formatDecimal(am) + "M" + } else if (abs(value / 1000) >= 1) { + am = value / 1000 + return formatDecimal(am) + "K" + } else { + return formatDecimal(value) + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/config/LineGraphConfigurations.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/config/LineGraphConfigurations.kt new file mode 100644 index 0000000..2071484 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/config/LineGraphConfigurations.kt @@ -0,0 +1,220 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.linegraph.config + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Defines the appearance of the line drawn on the graph. + * + * Controls the visual style of the primary data line including its color, + * thickness, stroke cap, and whether a smooth cubic Bézier curve or + * straight segments are used to connect data points. + * + * @property lineColor Color of the line stroke. + * @property lineWidth Thickness of the line in [Dp]. + * @property strokeCap The cap style applied to both ends of each line segment. + * @property smoothCurve When `true`, adjacent points are connected with + * cubic Bézier curves (inspired by Vico's + * `CubicPointConnector`). When `false`, straight + * line segments are used. + * @property curvature Controls the strength of the cubic Bézier curve. + * A value of `0` produces straight lines while `1` yields + * the most pronounced curves. Only effective when + * [smoothCurve] is `true`. + */ +@Stable +data class LineGraphLineConfig( + val lineColor: Color, + val lineWidth: Dp, + val strokeCap: StrokeCap, + val smoothCurve: Boolean, + val curvature: Float, +) + +/** + * Configures the area fill rendered beneath the line. + * + * The fill is drawn from the line path down to the X-axis and is + * typically a semi-transparent gradient that reinforces the data trend. + * + * @property enabled Whether the area fill is drawn. + * @property brush The [Brush] used to paint the area. A vertical gradient + * from the line color (with some alpha) to transparent + * works well for most use cases. + */ +@Stable +data class LineGraphAreaFillConfig( + val enabled: Boolean, + val brush: Brush +) + +/** + * Configures the dots drawn at each data point on the line. + * + * @property enabled Whether data-point dots are visible. + * @property radius Radius of each dot in [Dp]. + * @property color Fill color of the dot. + * @property borderColor Stroke color drawn around the dot. + * @property borderWidth Stroke width of the dot border in [Dp]. + */ +@Stable +data class LineGraphPointConfig( + val enabled: Boolean, + val radius: Dp, + val color: Color, + val borderColor: Color, + val borderWidth: Dp +) + +/** + * Configuration for the X-axis of the line graph. + * + * @property isAxisScaleEnabled Whether the X-axis labels are drawn. + * @property isAxisLineEnabled Whether the horizontal axis line is visible. + * @property axisLineWidth Thickness of the axis line. + * @property axisLineShape Shape of the axis line (e.g., rounded corners). + * @property axisLineColor Color of the axis line. + * @property textStyle [TextStyle] applied to the X-axis labels. + */ +@Stable +data class LineGraphXAxisConfig( + val isAxisScaleEnabled: Boolean, + val isAxisLineEnabled: Boolean, + val axisLineWidth: Dp, + val axisLineShape: Shape, + val axisLineColor: Color, + val textStyle: TextStyle, +) + +/** + * Configuration for the Y-axis of the line graph. + * + * @property isAxisScaleEnabled Whether the Y-axis scale labels are displayed. + * @property isAxisLineEnabled Whether the vertical axis line is visible. + * @property axisLineWidth Thickness of the axis line. + * @property axisLineShape Shape of the axis line. + * @property axisLineColor Color of the axis line. + * @property axisScaleCount Number of divisions on the Y-axis scale. This + * determines how many horizontal grid lines and + * labels are rendered. + * @property textStyle [TextStyle] for the scale labels. + * @property textPrefix Text prepended to each scale label (e.g., "$"). + * @property textPostfix Text appended to each scale label (e.g., "%"). + */ +@Stable +data class LineGraphYAxisConfig( + val isAxisScaleEnabled: Boolean, + val isAxisLineEnabled: Boolean, + val axisLineWidth: Dp, + val axisLineShape: Shape, + val axisLineColor: Color, + val axisScaleCount: Int, + val textStyle: TextStyle, + val textPrefix: String, + val textPostfix: String +) + +/** + * Configuration for horizontal grid lines drawn behind the line graph. + * + * Grid lines help the reader judge Y-axis values at a glance. They + * support dashed styling through [dashLength] and [gapLength]. + * + * @property color Color of the grid lines. + * @property strokeWidth Width of each grid line. + * @property dashLength Length of each dash in the dashed pattern. + * @property gapLength Gap between dashes. + * @property totalGridLines Number of horizontal grid lines. + * @property dashCap [StrokeCap] applied to each dash segment. + */ +@Stable +data class LineGraphGridLineStyle( + val color: Color, + val strokeWidth: Dp, + val dashLength: Dp = 0.dp, + val gapLength: Dp = 0.dp, + val totalGridLines: Int = 4, + val dashCap: StrokeCap = StrokeCap.Square +) + +/** + * Configures the pop-up tooltip shown when interacting with a data point. + * + * @property enabled Whether touch/click tooltips are active. + * @property background Background color of the tooltip. + * @property shape Shape of the tooltip container. + * @property textStyle [TextStyle] for the tooltip label. + */ +@Stable +data class LineGraphPopUpConfig( + val enabled: Boolean, + val background: Color, + val shape: Shape, + val textStyle: TextStyle +) + +/** + * Animation configuration for the line graph's entrance effect. + * + * The chart can animate both its line drawing and area fill when it + * first appears on screen. + * + * @property enabled Whether animation is played on first composition. + * @property durationMillis Total duration of the animation in milliseconds. + * @property delayMillis Delay before the animation starts. + */ +@Stable +data class LineGraphAnimationConfig( + val enabled: Boolean, + val durationMillis: Int, + val delayMillis: Int = 0 +) + +/** + * Configuration for the live-update feature of the line graph. + * + * When enabled, the graph reacts to data changes with a smooth path + * transition animation and a blinking indicator on the last data point, + * making it ideal for real-time / streaming dashboards. + * + * @property enabled Whether the live-update mode is active. + * @property blinkEnabled Whether the last data point pulses. + * @property blinkDurationMillis Duration of one pulse cycle (expand → shrink). + * @property blinkMinRadius Minimum radius of the blinking circle (in dp-like float). + * @property blinkMaxRadius Maximum radius of the blinking circle. + * @property blinkColor Color of the blinking pulse. When `null` the line color is used. + * @property pathTransitionDurationMillis Duration of the smooth path transition + * when data changes. + */ +@Stable +data class LineGraphLiveUpdateConfig( + val enabled: Boolean, + val blinkEnabled: Boolean, + val blinkDurationMillis: Int, + val blinkMinRadius: Float, + val blinkMaxRadius: Float, + val blinkColor: Color?, + val pathTransitionDurationMillis: Int, +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/config/LineGraphDefaults.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/config/LineGraphDefaults.kt new file mode 100644 index 0000000..976ec38 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/config/LineGraphDefaults.kt @@ -0,0 +1,316 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.linegraph.config + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Default factory functions and constants for configuring a [LineGraph][com.developerstring.jetco_kmp.charts.linegraph.LineGraph]. + * + * Each function mirrors one of the configuration data classes and + * provides sensible defaults, making it easy for consumers to + * customise only the properties they care about. + * + * **Example:** + * ``` + * LineGraph( + * chartData = mapOf("Jan" to 10f, "Feb" to 20f), + * lineConfig = LineGraphDefaults.lineConfig(lineColor = Color.Red), + * yAxisConfig = LineGraphDefaults.yAxisConfig(axisScaleCount = 6), + * ) + * ``` + */ +object LineGraphDefaults { + + // ── shared text style ──────────────────────────────────────────────── + /** + * Default [TextStyle] used for axis labels and tooltips. + */ + private val textStyle = TextStyle( + fontSize = 14.sp, + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + color = Color.Black, + ) + + /** Default primary line color. */ + private val defaultLineColor = Color(0xFF519DE9) + + // ── line ───────────────────────────────────────────────────────────── + + /** + * Creates a [LineGraphLineConfig] with customisable defaults. + * + * @param lineColor Stroke color of the line. + * @param lineWidth Stroke thickness. + * @param strokeCap Cap style for each segment. + * @param smoothCurve Whether to connect points with cubic Bézier curves. + * @param curvature Bézier curvature strength in `(0, 1]`. + * @return Configured [LineGraphLineConfig]. + */ + fun lineConfig( + lineColor: Color = defaultLineColor, + lineWidth: Dp = 2.dp, + strokeCap: StrokeCap = StrokeCap.Round, + smoothCurve: Boolean = true, + curvature: Float = 0.5f + ) = LineGraphLineConfig( + lineColor = lineColor, + lineWidth = lineWidth, + strokeCap = strokeCap, + smoothCurve = smoothCurve, + curvature = curvature + ) + + // ── area fill ──────────────────────────────────────────────────────── + + /** + * Creates a [LineGraphAreaFillConfig]. + * + * When [enabled] the area below the line receives a gradient fill that + * fades from the [lineColor] at 40 % opacity down to transparent. + * + * @param enabled Whether the area fill is rendered. + * @param lineColor Used to derive the default gradient brush. + * @param brush Custom brush; overrides the default gradient. + * @return Configured [LineGraphAreaFillConfig]. + */ + fun areaFillConfig( + enabled: Boolean = true, + lineColor: Color = defaultLineColor, + brush: Brush = Brush.verticalGradient( + listOf( + lineColor.copy(alpha = 0.4f), + Color.Transparent + ) + ) + ) = LineGraphAreaFillConfig( + enabled = enabled, + brush = brush + ) + + // ── data-point dots ────────────────────────────────────────────────── + + /** + * Creates a [LineGraphPointConfig]. + * + * @param enabled Whether dots are drawn at each data point. + * @param radius Dot radius. + * @param color Dot fill color. + * @param borderColor Dot border color. + * @param borderWidth Dot border thickness. + * @return Configured [LineGraphPointConfig]. + */ + fun pointConfig( + enabled: Boolean = true, + radius: Dp = 4.dp, + color: Color = Color.White, + borderColor: Color = defaultLineColor, + borderWidth: Dp = 2.dp + ) = LineGraphPointConfig( + enabled = enabled, + radius = radius, + color = color, + borderColor = borderColor, + borderWidth = borderWidth + ) + + // ── X-axis ─────────────────────────────────────────────────────────── + + /** + * Creates a [LineGraphXAxisConfig]. + * + * @param isAxisScaleEnabled Whether labels are shown on the X-axis. + * @param isAxisLineEnabled Whether the horizontal axis line is drawn. + * @param axisLineWidth Thickness of the axis line. + * @param axisLineShape Shape of the axis line. + * @param axisLineColor Color of the axis line. + * @param textStyle Style for the axis labels. + * @return Configured [LineGraphXAxisConfig]. + */ + fun xAxisConfig( + isAxisScaleEnabled: Boolean = true, + isAxisLineEnabled: Boolean = true, + axisLineWidth: Dp = 2.dp, + axisLineShape: Shape = RoundedCornerShape(3.dp), + axisLineColor: Color = Color.LightGray, + textStyle: TextStyle = LineGraphDefaults.textStyle, + ) = LineGraphXAxisConfig( + isAxisScaleEnabled = isAxisScaleEnabled, + isAxisLineEnabled = isAxisLineEnabled, + axisLineWidth = axisLineWidth, + axisLineShape = axisLineShape, + axisLineColor = axisLineColor, + textStyle = textStyle, + ) + + // ── Y-axis ─────────────────────────────────────────────────────────── + + /** + * Creates a [LineGraphYAxisConfig]. + * + * @param isAxisScaleEnabled Whether scale labels are displayed. + * @param isAxisLineEnabled Whether the vertical axis line is shown. + * @param axisLineWidth Thickness of the axis line. + * @param axisLineShape Shape of the axis line. + * @param axisLineColor Color of the axis line. + * @param axisScaleCount Number of intervals on the Y-axis. + * @param textStyle Style for the scale labels. + * @param textPrefix Prefix before each label value. + * @param textPostfix Postfix after each label value. + * @return Configured [LineGraphYAxisConfig]. + */ + fun yAxisConfig( + isAxisScaleEnabled: Boolean = true, + isAxisLineEnabled: Boolean = true, + axisLineWidth: Dp = 2.dp, + axisLineShape: Shape = RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp), + axisLineColor: Color = Color.LightGray, + axisScaleCount: Int = 4, + textStyle: TextStyle = LineGraphDefaults.textStyle, + textPrefix: String = "", + textPostfix: String = "" + ) = LineGraphYAxisConfig( + isAxisScaleEnabled = isAxisScaleEnabled, + isAxisLineEnabled = isAxisLineEnabled, + axisLineWidth = axisLineWidth, + axisLineShape = axisLineShape, + axisLineColor = axisLineColor, + axisScaleCount = axisScaleCount, + textStyle = textStyle, + textPrefix = textPrefix, + textPostfix = textPostfix + ) + + // ── grid lines ─────────────────────────────────────────────────────── + + /** + * Creates a [LineGraphGridLineStyle]. + * + * @param color Grid line color. + * @param strokeWidth Grid line thickness. + * @param dashLength Dash segment length. + * @param gapLength Gap between dash segments. + * @param totalGridLines Number of horizontal grid lines. + * @param dashCap Cap style for each dash. + * @return Configured [LineGraphGridLineStyle]. + */ + fun gridLineStyle( + color: Color = Color.LightGray, + strokeWidth: Dp = 1.dp, + dashLength: Dp = 8.dp, + gapLength: Dp = 8.dp, + totalGridLines: Int = 4, + dashCap: StrokeCap = StrokeCap.Square + ) = LineGraphGridLineStyle( + color = color, + strokeWidth = strokeWidth, + dashLength = dashLength, + gapLength = gapLength, + totalGridLines = totalGridLines, + dashCap = dashCap + ) + + // ── pop-up / tooltip ───────────────────────────────────────────────── + + /** + * Creates a [LineGraphPopUpConfig]. + * + * @param enabled Whether tooltips are enabled. + * @param background Tooltip background color. + * @param shape Tooltip container shape. + * @param textStyle Text style for the tooltip content. + * @return Configured [LineGraphPopUpConfig]. + */ + fun popUpConfig( + enabled: Boolean = true, + background: Color = Color(0xFFCCC2DC), + shape: Shape = RoundedCornerShape(25), + textStyle: TextStyle = LineGraphDefaults.textStyle + ) = LineGraphPopUpConfig( + enabled = enabled, + background = background, + shape = shape, + textStyle = textStyle + ) + + // ── animation ──────────────────────────────────────────────────────── + + /** + * Creates a [LineGraphAnimationConfig]. + * + * @param enabled Whether the entrance animation is played. + * @param durationMillis Total animation duration. + * @param delayMillis Delay before the animation starts. + * @return Configured [LineGraphAnimationConfig]. + */ + fun animationConfig( + enabled: Boolean = true, + durationMillis: Int = 1000, + delayMillis: Int = 0 + ) = LineGraphAnimationConfig( + enabled = enabled, + durationMillis = durationMillis, + delayMillis = delayMillis + ) + + // ── live update ────────────────────────────────────────────────────── + + /** + * Creates a [LineGraphLiveUpdateConfig]. + * + * When enabled the graph animates path transitions when + * data changes, and optionally pulses the last data point. + * + * @param enabled Whether live-update mode is active. + * @param blinkEnabled Whether the last point pulses. + * @param blinkDurationMillis Duration of one blink cycle. + * @param blinkMinRadius Minimum pulse radius. + * @param blinkMaxRadius Maximum pulse radius. + * @param blinkColor Pulse color; `null` uses the line color. + * @param pathTransitionDurationMillis Duration of the smooth + * path transition animation. + * @return Configured [LineGraphLiveUpdateConfig]. + */ + fun liveUpdateConfig( + enabled: Boolean = false, + blinkEnabled: Boolean = true, + blinkDurationMillis: Int = 800, + blinkMinRadius: Float = 4f, + blinkMaxRadius: Float = 12f, + blinkColor: Color? = null, + pathTransitionDurationMillis: Int = 500, + ) = LineGraphLiveUpdateConfig( + enabled = enabled, + blinkEnabled = blinkEnabled, + blinkDurationMillis = blinkDurationMillis, + blinkMinRadius = blinkMinRadius, + blinkMaxRadius = blinkMaxRadius, + blinkColor = blinkColor, + pathTransitionDurationMillis = pathTransitionDurationMillis, + ) +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/model/LineGraphEntry.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/model/LineGraphEntry.kt new file mode 100644 index 0000000..58a2214 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/model/LineGraphEntry.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.linegraph.model + +import androidx.compose.runtime.Stable + +/** + * Represents a single data point on the line graph. + * + * Each entry maps a labelled position on the X-axis to a numeric Y-axis value. + * The [normalizedY] field is computed internally as a fraction of the maximum Y + * value, enabling proportional rendering within the chart canvas. + * + * @property label The text label displayed on the X-axis for this data point. + * @property value The raw Y-axis value of this data point. + * @property normalizedY The Y value normalized to the range [0, 1] relative to the + * maximum value across all entries. Used for positioning within + * the chart drawing area. + */ +@Stable +data class LineGraphEntry( + val label: String, + val value: Float, + val normalizedY: Float +) + +/** + * Converts a [Map] of label-value pairs into a list of [LineGraphEntry] objects. + * + * The conversion normalizes every value relative to [maxValue], producing + * a [LineGraphEntry.normalizedY] in `[0, 1]` for each entry. When [maxValue] is `0` the + * normalized value defaults to `0` to avoid division by zero. + * + * **Example:** + * ``` + * val data = mapOf("Jan" to 10f, "Feb" to 20f, "Mar" to 15f) + * val entries = data.mapToLineGraphEntries(maxValue = 20f) + * // entries[0] → LineGraphEntry("Jan", 10f, 0.5f) + * // entries[1] → LineGraphEntry("Feb", 20f, 1.0f) + * // entries[2] → LineGraphEntry("Mar", 15f, 0.75f) + * ``` + * + * @param maxValue The upper bound used for normalization. Typically the largest + * value in the data set or a user-supplied ceiling. + * @return An ordered list of [LineGraphEntry] instances ready for rendering. + */ +fun Map.mapToLineGraphEntries(maxValue: Float): List = + map { (key, value) -> + val safeValue = if (value.isNaN() || value.isInfinite()) 0f else value + val safeMax = if (maxValue.isNaN() || maxValue.isInfinite() || maxValue == 0f) 1f else maxValue + LineGraphEntry( + label = key, + value = safeValue, + normalizedY = (safeValue / safeMax).coerceIn(0f, 1f) + ) + } diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/model/LineGraphSeries.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/model/LineGraphSeries.kt new file mode 100644 index 0000000..4184901 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/linegraph/model/LineGraphSeries.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.charts.linegraph.model + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color + +/** + * Describes a single data series to be drawn in the line graph. + * + * Each series is rendered as a separate line on the same chart + * canvas, allowing side-by-side comparison of multiple data sets. + * + * @property name Human-readable name for the series (used in legends). + * @property data Ordered list of Y-axis values. The X-axis position + * is determined by the index and the shared label list + * passed to the chart. + * @property color The color used for the line, dots, and area fill + * of this series. + * + * Example: + * ``` + * val series = listOf( + * LineGraphSeries("Revenue", listOf(100f, 200f, 150f, 300f), Color.Blue), + * LineGraphSeries("Expense", listOf(80f, 120f, 90f, 200f), Color.Red), + * ) + * ``` + */ +@Stable +data class LineGraphSeries( + val name: String, + val data: List, + val color: Color +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/piechart/PieChart.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/piechart/PieChart.kt index 07816df..04c21d7 100644 --- a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/piechart/PieChart.kt +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/charts/piechart/PieChart.kt @@ -217,7 +217,26 @@ private fun PieChartItems( ) { data.forEach { item -> - val decimalFormat = item.value.toString().replace(".0", "") + val decimalFormat = run { + val v = item.value + if (v.isNaN() || v.isInfinite()) "0" + else { + val rounded = kotlin.math.round(v * 100) / 100 + val longVal = rounded.toLong() + if (rounded == longVal.toFloat()) longVal.toString() + else { + val absR = kotlin.math.round(kotlin.math.abs(v) * 100).toLong() + val intP = absR / 100 + val decP = (absR % 100).toInt() + val prefix = if (v < 0f && (intP > 0 || decP > 0)) "-" else "" + when { + decP == 0 -> "$prefix$intP" + decP % 10 == 0 -> "$prefix$intP.${decP / 10}" + else -> "$prefix$intP.${decP.toString().padStart(2, '0')}" + } + } + } + } SuggestionChip( onClick = { diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/MorphFloatingActionButton.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/MorphFloatingActionButton.kt new file mode 100644 index 0000000..6e04256 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/MorphFloatingActionButton.kt @@ -0,0 +1,278 @@ +package com.developerstring.jetco_kmp.components.button.fab + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.VectorConverter +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.button.fab.base.DefaultFabItem +import com.developerstring.jetco_kmp.components.button.fab.base.DefaultFloatingActionButton +import com.developerstring.jetco_kmp.components.button.fab.base.DefaultMorphCard +import com.developerstring.jetco_kmp.components.button.fab.model.FabItem +import com.developerstring.jetco_kmp.components.button.fab.model.FabMainConfig +import com.developerstring.jetco_kmp.components.button.fab.model.MorphFabItem +import com.developerstring.jetco_kmp.components.button.fab.scope.MorphCardScope +import com.developerstring.jetco_kmp.components.button.fab.utils.LocalFabButtonColor +import com.developerstring.jetco_kmp.components.button.fab.utils.animateFabButton +import com.developerstring.jetco_kmp.components.button.fab.utils.animateFabItem +import com.developerstring.jetco_kmp.components.button.fab.utils.fabItemStaggerDelay +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.jvm.JvmName + +/** + * A floating action button that morphs into a card containing a grid of actions. + * + * This component provides a circular FAB that, when expanded, smoothly transitions + * into a rectangular card layout. The card contains a grid of items arranged + * in a [FlowRow], with customizable header space and item arrangement. + * + * @param expanded Whether the FAB is currently morphed into a card. + * @param items List of [FabItem] to be displayed in the card grid. + * @param modifier Modifier applied to the root container. + * @param onClick Callback triggered when the FAB is clicked or the card's close action is invoked. + * @param config Comprehensive configuration for styling and layout. See [FabMainConfig]. + * @param content Optional custom composable for the collapsed FAB button. + * @param card Optional custom composable for the expanded card shell. Use [MorphCardScope.MorphItems] to place the grid. + * + * @see FabMainConfig for detailed arrangement and animation options. + * @see MorphCardScope for customizing the expanded card layout. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MorphFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + }, + card: @Composable MorphCardScope.() -> Unit = { + DefaultMorphCard(config = config, onClose = onClick, scope = this) + } +) { + val mappedItems = items.map { item -> + MorphFabItem { + DefaultFabItem(item = item, onClick = { item.onClick() }) + } + } + + MorphFloatingActionButtonBase( + expanded = expanded, + items = mappedItems, + modifier = modifier, + onClick = onClick, + config = config, + content = content, + card = card + ) +} + +/** + * A floating action button that morphs into a card with custom [MorphFabItem]s. + * + * Use this overload when you need to provide fully custom composables for each item + * in the grid instead of the standard icon/button pair. + * + * @param expanded Whether the FAB is currently morphed into a card. + * @param items List of [MorphFabItem] containing custom composables. + * @param modifier Modifier applied to the root container. + * @param onClick Callback triggered when the FAB is clicked. + * @param config Configuration for styling and layout. + * @param content Optional custom composable for the collapsed FAB button. + * @param card Optional custom composable for the expanded card shell. + */ +@JvmName("MorphFloatingActionButtonCustom") +@Composable +fun MorphFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + }, + card: @Composable MorphCardScope.() -> Unit = { + DefaultMorphCard(config = config, onClose = onClick, scope = this) + } +) { + MorphFloatingActionButtonBase( + expanded = expanded, + items = items, + modifier = modifier, + onClick = onClick, + config = config, + content = content, + card = card + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun MorphFloatingActionButtonBase( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + }, + card: @Composable MorphCardScope.() -> Unit = { + DefaultMorphCard(config = config, onClose = onClick, scope = this) + } +) { + val morph = config.itemArrangement.morph + var cardVisible by remember { mutableStateOf(false) } + + val fabOffsetX = remember { Animatable(0.dp, Dp.VectorConverter) } + val fabOffsetY = remember { Animatable(0.dp, Dp.VectorConverter) } + val fabScale = remember { Animatable(1f) } + val fabRotation = remember { Animatable(0f) } + val fabColor = remember { Animatable(config.buttonStyle.color, Color.VectorConverter(config.buttonStyle.color.colorSpace)) } + + LaunchedEffect(expanded) { + val btnTransition = if (expanded) config.animation.buttonEnterTransition + else config.animation.buttonExitTransition + + if (expanded) { + cardVisible = false + + launch { + animateFabButton( + btnTransition = btnTransition, + fabOffsetX = fabOffsetX, + fabOffsetY = fabOffsetY, + fabScale = fabScale, + fabRotation = fabRotation, + fabColor = fabColor, + ) + } + + delay(config.animation.itemEnterDelay) + + cardVisible = true + } else { + cardVisible = false + + launch { + animateFabButton( + btnTransition = btnTransition, + fabOffsetX = fabOffsetX, + fabOffsetY = fabOffsetY, + fabScale = fabScale, + fabRotation = fabRotation, + fabColor = fabColor, + ) + } + } + } + + val itemsContent: @Composable () -> Unit = { + Spacer(modifier = Modifier.size(morph.headerSpace)) + + FlowRow( + maxItemsInEachRow = morph.columns, + horizontalArrangement = Arrangement.spacedBy(morph.spacedBy, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(morph.spacedBy), + modifier = Modifier.fillMaxWidth() + ) { + items.forEachIndexed { index, morphItem -> + val alpha = remember(index) { Animatable(0f) } + val scale = remember(index) { Animatable(0f) } + val rotation = remember(index) { Animatable(0f) } + + LaunchedEffect(cardVisible) { + val transition = if (cardVisible) config.animation.itemEnterTransition + else config.animation.itemExitTransition + + val staggerDelay = fabItemStaggerDelay( + expanded = cardVisible, + index = index, + total = items.size - 1, + totalCount = (items.size - 1).coerceAtLeast(1), + config = config + ) + delay(staggerDelay) + + animateFabItem( + expanded = cardVisible, + transition = transition, + alpha = alpha, + scale = scale, + rotation = rotation, + ) + } + + Box( + modifier = Modifier.graphicsLayer { + this.alpha = alpha.value + this.scaleX = scale.value + this.scaleY = scale.value + this.rotationZ = rotation.value + } + ) { + morphItem.content() + } + } + } + } + + val scope = MorphCardScope(itemsContent = itemsContent) + + Box(modifier = modifier) { + AnimatedVisibility( + visible = cardVisible, + enter = fadeIn() + scaleIn(initialScale = 0.92f), + exit = fadeOut() + scaleOut(targetScale = 0.82f), + modifier = Modifier.align(Alignment.BottomEnd) + ) { + CompositionLocalProvider(LocalFabButtonColor provides fabColor.value) { + scope.card() + } + } + + CompositionLocalProvider(LocalFabButtonColor provides fabColor.value) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = fabOffsetX.value, y = fabOffsetY.value) + .graphicsLayer { + scaleX = fabScale.value + scaleY = fabScale.value + rotationZ = fabRotation.value + alpha = if (cardVisible) 0f else 1f + } + ) { + content() + } + } + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/RadialFloatingActionButton.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/RadialFloatingActionButton.kt new file mode 100644 index 0000000..b9ead83 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/RadialFloatingActionButton.kt @@ -0,0 +1,221 @@ +package com.developerstring.jetco_kmp.components.button.fab + +import androidx.compose.animation.VectorConverter +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.button.fab.base.DefaultFabItem +import com.developerstring.jetco_kmp.components.button.fab.base.DefaultFloatingActionButton +import com.developerstring.jetco_kmp.components.button.fab.model.FabItem +import com.developerstring.jetco_kmp.components.button.fab.model.FabMainConfig +import com.developerstring.jetco_kmp.components.button.fab.model.RadialFabItem +import com.developerstring.jetco_kmp.components.button.fab.utils.LocalFabButtonColor +import com.developerstring.jetco_kmp.components.button.fab.utils.animateFabButton +import com.developerstring.jetco_kmp.components.button.fab.utils.animateFabItem +import com.developerstring.jetco_kmp.components.button.fab.utils.fabItemStaggerDelay +import kotlinx.coroutines.delay +import kotlin.jvm.JvmName +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * A floating action button that expands items in a radial (arc) pattern. + * + * This component provides a main FAB that, when expanded, reveals a set of items + * arranged along a configurable arc. It supports both simple [FabItem]s (icons with actions) + * and custom [RadialFabItem]s for full composable control. + * + * @param expanded Whether the FAB is currently showing its items. + * @param items List of [FabItem] to be displayed as buttons. + * @param modifier Modifier applied to the root container of the FAB. + * @param onClick Callback triggered when the main FAB button is clicked. + * @param config Configuration for styling and animations. See [FabMainConfig]. + * @param content Optional custom composable for the main FAB button. Defaults to [DefaultFloatingActionButton]. + * + * @see FabMainConfig for detailed arrangement and animation options. + * @see FabItem for item data model. + */ +@Composable +fun RadialFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + } +) { + val mappedItems = items.map { item -> + RadialFabItem { + DefaultFabItem(item = item, onClick = { item.onClick() }) + } + } + + RadialFloatingActionButtonBase( + expanded = expanded, + items = mappedItems, + modifier = modifier, + onClick = onClick, + config = config, + content = content + ) +} + +/** + * A floating action button that expands custom [RadialFabItem]s in a radial (arc) pattern. + * + * Use this overload when you need to provide fully custom composables for each item + * instead of the standard icon/button pair. + * + * @param expanded Whether the FAB is currently showing its items. + * @param items List of [RadialFabItem] containing custom composables. + * @param modifier Modifier applied to the root container of the FAB. + * @param onClick Callback triggered when the main FAB button is clicked. + * @param config Configuration for styling and animations. + * @param content Optional custom composable for the main FAB button. + */ +@JvmName("RadialFloatingActionButtonCustom") +@Composable +fun RadialFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + } +) { + RadialFloatingActionButtonBase( + expanded = expanded, + items = items, + modifier = modifier, + onClick = onClick, + config = config, + content = content + ) +} + +@Composable +internal fun RadialFloatingActionButtonBase( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + } +) { + Box( + modifier = modifier, + contentAlignment = Alignment.BottomEnd + ) { + val fabOffsetX = remember { Animatable(0.dp, Dp.VectorConverter) } + val fabOffsetY = remember { Animatable(0.dp, Dp.VectorConverter) } + val fabScale = remember { Animatable(1f) } + val fabRotation = remember { Animatable(0f) } + val fabColor = remember { Animatable(config.buttonStyle.color, Color.VectorConverter(config.buttonStyle.color.colorSpace)) } + + items.forEachIndexed { index, radialItem -> + val startAngle = config.itemArrangement.radial.arc.start + val endAngle = config.itemArrangement.radial.arc.end + + val angleDeg = if (items.size == 1) { + (startAngle + endAngle) / 2.0 + } else { + startAngle + (endAngle - startAngle) * (index.toDouble() / (items.size - 1)) + } + + val angleRad = angleDeg * PI / 180.0 + + val targetOffsetX = (config.itemArrangement.radial.radius.value * cos(angleRad)).dp + val targetOffsetY = (config.itemArrangement.radial.radius.value * sin(angleRad)).dp + + val rotation = remember { Animatable(0f) } + val scale = remember { Animatable(0f) } + val alpha = remember { Animatable(0f) } + val offsetX = remember { Animatable(0.dp, Dp.VectorConverter) } + val offsetY = remember { Animatable(0.dp, Dp.VectorConverter) } + + LaunchedEffect(expanded) { + val transition = if (expanded) config.animation.itemEnterTransition else config.animation.itemExitTransition + val staggerDelay = fabItemStaggerDelay( + expanded = expanded, + index = index, + total = items.size - 1, + totalCount = (items.size - 1).coerceAtLeast(1), + config = config + ) + val enterDelay = if (expanded) config.animation.itemEnterDelay else 0L + delay(enterDelay + staggerDelay) + + animateFabItem( + expanded = expanded, + transition = transition, + alpha = alpha, + scale = scale, + rotation = rotation, + offsetX = offsetX, + offsetY = offsetY, + targetOffsetX = if (expanded) targetOffsetX else fabOffsetX.value, + targetOffsetY = if (expanded) targetOffsetY else -fabOffsetY.value, + ) + } + + Box( + modifier = Modifier + .offset(x = offsetX.value + fabOffsetX.value - 8.dp, y = -offsetY.value + fabOffsetY.value) + .graphicsLayer { + this.alpha = alpha.value + this.scaleX = scale.value + this.scaleY = scale.value + this.rotationZ = rotation.value + } + ) { + radialItem.content() + } + } + + LaunchedEffect(expanded) { + val btnTransition = if (expanded) config.animation.buttonEnterTransition + else config.animation.buttonExitTransition + + animateFabButton( + btnTransition = btnTransition, + fabOffsetX = fabOffsetX, + fabOffsetY = fabOffsetY, + fabScale = fabScale, + fabRotation = fabRotation, + fabColor = fabColor, + ) + } + + CompositionLocalProvider(LocalFabButtonColor provides fabColor.value) { + Box( + modifier = Modifier + .offset(x = fabOffsetX.value, y = fabOffsetY.value) + .graphicsLayer { + scaleX = fabScale.value + scaleY = fabScale.value + rotationZ = fabRotation.value + } + ) { + content() + } + } + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/StackFloatingActionButton.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/StackFloatingActionButton.kt new file mode 100644 index 0000000..eb831c5 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/StackFloatingActionButton.kt @@ -0,0 +1,303 @@ +package com.developerstring.jetco_kmp.components.button.fab + +import androidx.compose.animation.VectorConverter +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.button.fab.base.DefaultFabItem +import com.developerstring.jetco_kmp.components.button.fab.base.DefaultFloatingActionButton +import com.developerstring.jetco_kmp.components.button.fab.model.FabItem +import com.developerstring.jetco_kmp.components.button.fab.model.FabMainConfig +import com.developerstring.jetco_kmp.components.button.fab.model.StackDirection +import com.developerstring.jetco_kmp.components.button.fab.model.StackExpandOffset +import com.developerstring.jetco_kmp.components.button.fab.model.StackFabItem +import com.developerstring.jetco_kmp.components.button.fab.utils.LocalFabButtonColor +import com.developerstring.jetco_kmp.components.button.fab.utils.animateFabButton +import com.developerstring.jetco_kmp.components.button.fab.utils.animateFabItem +import com.developerstring.jetco_kmp.components.button.fab.utils.fabItemStaggerDelay +import com.developerstring.jetco_kmp.components.button.fab.utils.resolvedFinalOffset +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.jvm.JvmName + +/** + * A floating action button that expands items in a linear stack (Vertical or Horizontal). + * + * This component allows for expanding items in multiple directions (TOP, START, END) + * simultaneously. It also provides an [onExpandChange] callback that reports the total + * displacement of the main button, which can be used to "push" other UI elements + * (like screen content) out of the way. + * + * @param expanded Whether the FAB is currently showing its items. + * @param items List of [FabItem] to be displayed as buttons. Defaults to [StackDirection.TOP]. + * @param modifier Modifier applied to the root container. + * @param onClick Callback triggered when the main FAB button is clicked. + * @param config Configuration for styling and animations. See [FabMainConfig]. + * @param onExpandChange Callback that provides [StackExpandOffset]. + * @param content Optional custom composable for the main FAB button. + * + * @see StackExpandOffset for handling screen content displacement. + * @see StackFabItem for custom composable items with specific directions. + */ +@Composable +fun StackFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + onExpandChange: (StackExpandOffset) -> Unit = {}, + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + } +) { + val stackItems = items.map { item -> + StackFabItem { + DefaultFabItem(item = item, onClick = { item.onClick() }) + } + } + + StackFloatingActionButtonBase( + expanded = expanded, + items = stackItems, + modifier = modifier, + onClick = onClick, + config = config, + onExpandChange = onExpandChange, + content = content + ) +} + +/** + * A floating action button that expands custom [StackFabItem]s in a linear stack. + * + * Use this overload to specify different directions for each item or to use custom composables. + * + * @param expanded Whether the FAB is currently showing its items. + * @param items List of [StackFabItem] with direction and custom content. + * @param modifier Modifier applied to the root container. + * @param onClick Callback triggered when the main FAB button is clicked. + * @param config Configuration for styling and animations. + * @param onExpandChange Callback that provides [StackExpandOffset] data. + * @param content Optional custom composable for the main FAB button. + */ +@JvmName("StackFloatingActionButtonCustom") +@Composable +fun StackFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + onExpandChange: (StackExpandOffset) -> Unit = {}, + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + } +) { + StackFloatingActionButtonBase( + expanded = expanded, + items = items, + modifier = modifier, + onClick = onClick, + config = config, + onExpandChange = onExpandChange, + content = content + ) +} + +@Composable +internal fun StackFloatingActionButtonBase( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + onExpandChange: (StackExpandOffset) -> Unit = {}, + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + } +) { + val density = LocalDensity.current + var fabWidthDp by remember { mutableStateOf(config.buttonStyle.size) } + var fabHeightDp by remember { mutableStateOf(config.buttonStyle.size) } + val spacedBy = config.itemArrangement.stack.spacedBy + val spacingPadding = config.itemArrangement.stack.spacingPadding + + val itemWidths = remember(items.size) { + mutableStateListOf().also { list -> + repeat(items.size) { list.add(config.buttonStyle.size) } + } + } + val itemHeights = remember(items.size) { + mutableStateListOf().also { list -> + repeat(items.size) { list.add(config.buttonStyle.size) } + } + } + + val topIndices = remember(items) { items.indices.filter { items[it].direction == StackDirection.TOP } } + val startIndices = remember(items) { items.indices.filter { items[it].direction == StackDirection.START } } + val endIndices = remember(items) { items.indices.filter { items[it].direction == StackDirection.END } } + + Box( + modifier = modifier, + contentAlignment = Alignment.BottomEnd + ) { + val fabOffsetX = remember { Animatable(0.dp, Dp.VectorConverter) } + val fabOffsetY = remember { Animatable(0.dp, Dp.VectorConverter) } + val fabScale = remember { Animatable(1f) } + val fabRotation = remember { Animatable(0f) } + val fabColor = remember { Animatable(config.buttonStyle.color, Color.VectorConverter(config.buttonStyle.color.colorSpace)) } + + items.forEachIndexed { index, stackItem -> + val direction = stackItem.direction + + val groupIndices = when (direction) { + StackDirection.TOP -> topIndices + StackDirection.START -> startIndices + StackDirection.END -> endIndices + } + val positionInGroup = groupIndices.indexOf(index) + + val spacing = when (direction) { + StackDirection.TOP -> { + val heightBefore = (0 until positionInGroup).fold(0.dp) { acc, pos -> + acc + itemHeights[groupIndices[pos]] + spacedBy + } + fabHeightDp + spacedBy + heightBefore + } + StackDirection.START, + StackDirection.END -> { + val widthBefore = (0 until positionInGroup).fold(0.dp) { acc, pos -> + acc + itemWidths[groupIndices[pos]] + spacedBy + } + fabWidthDp + spacedBy + widthBefore + } + } + + val targetOffsetX = when (direction) { + StackDirection.START -> -spacing + StackDirection.END -> spacing + StackDirection.TOP -> -(fabWidthDp - itemWidths[index]) / 2 + } + + val targetOffsetY = when (direction) { + StackDirection.TOP -> -spacing + StackDirection.START, + StackDirection.END -> -(fabHeightDp - itemHeights[index]) / 2 + } + + val rotation = remember { Animatable(0f) } + val scale = remember { Animatable(0f) } + val alpha = remember { Animatable(0f) } + val offsetX = remember { Animatable(0.dp, Dp.VectorConverter) } + val offsetY = remember { Animatable(0.dp, Dp.VectorConverter) } + + val groupSize = groupIndices.size + + LaunchedEffect(expanded, targetOffsetX, targetOffsetY) { + val transition = if (expanded) config.animation.itemEnterTransition + else config.animation.itemExitTransition + + val staggerDelay = fabItemStaggerDelay( + expanded = expanded, + index = positionInGroup, + total = (groupSize - 1).coerceAtLeast(0), + totalCount = groupSize.coerceAtLeast(1), + config = config + ) + val enterDelay = if (expanded) config.animation.itemEnterDelay else 0L + delay(enterDelay + staggerDelay) + + animateFabItem( + expanded = expanded, + transition = transition, + alpha = alpha, + scale = scale, + rotation = rotation, + offsetX = offsetX, + offsetY = offsetY, + targetOffsetX = targetOffsetX, + targetOffsetY = targetOffsetY, + ) + } + + Box( + modifier = Modifier + .offset(x = offsetX.value + fabOffsetX.value, y = offsetY.value + fabOffsetY.value) + .onSizeChanged { size -> + val width = with(density) { size.width.toDp() } + val height = with(density) { size.height.toDp() } + if (itemWidths[index] != width) itemWidths[index] = width + if (itemHeights[index] != height) itemHeights[index] = height + }.graphicsLayer { + this.alpha = alpha.value + this.scaleX = scale.value + this.scaleY = scale.value + this.rotationZ = rotation.value + } + ) { + stackItem.content() + } + } + + LaunchedEffect(expanded) { + val btnTransition = if (expanded) config.animation.buttonEnterTransition + else config.animation.buttonExitTransition + + if (expanded) { + val (finalOffsetX, finalOffsetY) = btnTransition.resolvedFinalOffset() + onExpandChange(StackExpandOffset( + offsetY = fabHeightDp + spacingPadding - finalOffsetY, + offsetX = fabWidthDp + spacingPadding - finalOffsetX + )) + } else { + onExpandChange(StackExpandOffset()) + } + + launch { + animateFabButton( + btnTransition = btnTransition, + fabOffsetX = fabOffsetX, + fabOffsetY = fabOffsetY, + fabScale = fabScale, + fabRotation = fabRotation, + fabColor = fabColor, + ) + } + } + + CompositionLocalProvider(LocalFabButtonColor provides fabColor.value) { + Box( + modifier = Modifier + .offset(x = fabOffsetX.value, y = fabOffsetY.value) + .graphicsLayer { + scaleX = fabScale.value + scaleY = fabScale.value + rotationZ = fabRotation.value + }.onSizeChanged { size -> + fabWidthDp = with(density) { size.width.toDp() } + fabHeightDp = with(density) { size.height.toDp() } + } + ) { + content() + } + } + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/base/DefaultFabItem.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/base/DefaultFabItem.kt new file mode 100644 index 0000000..42865ae --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/base/DefaultFabItem.kt @@ -0,0 +1,48 @@ +package com.developerstring.jetco_kmp.components.button.fab.base + +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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +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 androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.button.fab.model.FabItem + +@Composable +internal fun DefaultFabItem( + item: FabItem, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + modifier = modifier + .size(item.buttonStyle.size) + .clip(item.buttonStyle.shape) + .background(item.buttonStyle.color) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current, + onClick = onClick + ) + ) { + item.icon?.let { icon -> + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(item.buttonStyle.size * 0.55f) + ) + } + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/base/DefaultFloatingActionButton.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/base/DefaultFloatingActionButton.kt new file mode 100644 index 0000000..ba7e9c4 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/base/DefaultFloatingActionButton.kt @@ -0,0 +1,53 @@ +package com.developerstring.jetco_kmp.components.button.fab.base + +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.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.Icon +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 com.developerstring.jetco_kmp.components.button.fab.model.FabMainConfig +import com.developerstring.jetco_kmp.components.button.fab.utils.LocalFabButtonColor + +@Composable +internal fun DefaultFloatingActionButton( + modifier: Modifier = Modifier, + onClick: (() -> Unit) = {}, + config: FabMainConfig = FabMainConfig() +) { + val animatedColor = LocalFabButtonColor.current + val resolvedColor = if (animatedColor == Color.Unspecified) config.buttonStyle.color else animatedColor + + Box( + modifier = modifier + .defaultMinSize(minWidth = config.buttonStyle.size) + .height(config.buttonStyle.size) + .clip(config.buttonStyle.shape) + .background(resolvedColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current + ) { onClick.invoke() }, + contentAlignment = Alignment.Center + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = "Base FAB icon", + tint = Color.White, + modifier = Modifier.size(config.buttonStyle.size * 0.55f) + ) + } + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/base/DefaultMorphCard.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/base/DefaultMorphCard.kt new file mode 100644 index 0000000..c60e528 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/base/DefaultMorphCard.kt @@ -0,0 +1,71 @@ +package com.developerstring.jetco_kmp.components.button.fab.base + +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.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +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.graphics.Color +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.button.fab.model.FabMainConfig +import com.developerstring.jetco_kmp.components.button.fab.scope.MorphCardScope + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun DefaultMorphCard( + config: FabMainConfig, + onClose: () -> Unit, + scope: MorphCardScope +) { + val morph = config.itemArrangement.morph + + Column( + modifier = Modifier + .width(morph.width) + .background( + color = config.buttonStyle.color, + shape = morph.cardShape + ).padding(16.dp) + ) { + Box(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.align(Alignment.CenterStart)) { + Text( + text = "Quick Actions", + color = Color.White + ) + } + Box( + modifier = Modifier + .size(32.dp) + .align(Alignment.CenterEnd) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClose + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + } + scope.MorphItems() + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/FabItem.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/FabItem.kt new file mode 100644 index 0000000..49177d0 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/FabItem.kt @@ -0,0 +1,44 @@ +package com.developerstring.jetco_kmp.components.button.fab.model + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.button.fab.MorphFloatingActionButton +import com.developerstring.jetco_kmp.components.button.fab.RadialFloatingActionButton +import com.developerstring.jetco_kmp.components.button.fab.StackFloatingActionButton + +/** + * Data model representing a standard item in a Floating Action Button (FAB) menu. + * + * This is the default item type used by [RadialFloatingActionButton], [StackFloatingActionButton], + * and [MorphFloatingActionButton]. It consists of an icon, a click action, and visual styling. + * + * @property onClick Callback triggered when this specific item is clicked. + * @property icon Icon to be displayed inside the item button. + * @property buttonStyle Visual configuration for this item, including color, shape, and size. + */ +@Stable +data class FabItem( + val onClick: () -> Unit, + val icon: ImageVector? = null, + val buttonStyle: ButtonStyle = ButtonStyle() +) { + + /** + * Visual style configuration for an individual [FabItem]. + * + * @property color Background color of the item button. + * @property shape Shape of the item button. Default is [CircleShape]. + * @property size Diameter/Height of the item button. Default is 52.dp. + */ + @Stable + data class ButtonStyle( + val color: Color = Color(0xFF1976D2), + val shape: Shape = CircleShape, + val size: Dp = 52.dp + ) +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/FabMainConfig.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/FabMainConfig.kt new file mode 100644 index 0000000..3b8903e --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/FabMainConfig.kt @@ -0,0 +1,105 @@ +package com.developerstring.jetco_kmp.components.button.fab.model + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.button.fab.transition.FabButtonTransition +import com.developerstring.jetco_kmp.components.button.fab.transition.FabItemTransition + +/** + * Main configuration class for the Floating Action Button (FAB) family. + * + * @property buttonStyle Visual styling for the main FAB (color, shape, size, etc.). + * @property itemArrangement Configuration for how items are positioned when expanded. + * @property animation Animation specs for entry/exit transitions and stagger orders. + */ +@Stable +data class FabMainConfig( + val buttonStyle: ButtonStyle = ButtonStyle(), + val itemArrangement: ItemArrangement = ItemArrangement(), + val animation: Animation = Animation() +) { + + sealed interface Orientation { + data class Radial( + val arc: Arc = Arc.END, + val radius: Dp = 80.dp + ) : Orientation { + enum class Arc(val start: Double, val end: Double) { + END(90.0, 180.0), + START(90.0, 0.0), + CENTER(0.0, 180.0) + } + } + + data class Stack( + val spacedBy: Dp = 40.dp, + val spacingPadding: Dp = 24.dp + ) : Orientation + + data class Morph( + val columns: Int = 2, + val spacedBy: Dp = 12.dp, + val headerSpace: Dp = 20.dp, + val width: Dp = 250.dp, + val cardShape: Shape = RoundedCornerShape(24.dp) + ) : Orientation + } + + /** + * Animation configuration for the FAB family. + * + * @property itemEnterOrder The order in which items appear (FIFO, FILO, ALL). + * @property itemExitOrder The order in which items disappear. + * @property itemEnterTransition Combined transitions for sub-item entry. + * @property itemExitTransition Combined transitions for sub-item exit. + * @property buttonEnterTransition Transitions applied to the main FAB on expansion. + * @property buttonExitTransition Transitions applied to the main FAB on collapse. + * @property itemEnterDelay Additional delay in milliseconds before items start their + * enter animation. + */ + @Stable + open class Animation( + val itemEnterOrder: StaggerOrder = StaggerOrder.FIFO, + val itemExitOrder: StaggerOrder = StaggerOrder.FILO, + val itemEnterTransition: FabItemTransition = FabItemTransition.Slide() + FabItemTransition.Fade(), + val itemExitTransition: FabItemTransition = FabItemTransition.Slide() + FabItemTransition.Fade(), + val buttonEnterTransition: FabButtonTransition = FabButtonTransition.Rotate(45f), + val buttonExitTransition: FabButtonTransition = FabButtonTransition.Rotate(0f), + val itemEnterDelay: Long = 300L, + ) + + @Stable + enum class StaggerOrder { + FIFO, + FILO, + ALL; + + internal fun delayFor(index: Int, total: Int, stepMs: Int): Long = when (this) { + FIFO -> (index * stepMs).toLong() + FILO -> ((total - 1 - index) * stepMs).toLong() + ALL -> 0L + } + } + + @Stable + data class ButtonStyle( + val color: Color = Color(0xFF1976D2), + val shape: Shape = CircleShape, + val size: Dp = 72.dp, + val iconRotation: Float = 45f, + val padding: PaddingValues = PaddingValues() + ) + + @Stable + data class ItemArrangement( + val radial: Orientation.Radial = Orientation.Radial(), + val stack: Orientation.Stack = Orientation.Stack(), + val morph: Orientation.Morph = Orientation.Morph() + ) +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/MorphFabItem.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/MorphFabItem.kt new file mode 100644 index 0000000..a1deef6 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/MorphFabItem.kt @@ -0,0 +1,15 @@ +package com.developerstring.jetco_kmp.components.button.fab.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import com.developerstring.jetco_kmp.components.button.fab.MorphFloatingActionButton + +/** + * A wrapper for a custom composable to be used as an item in [MorphFloatingActionButton]. + * + * @property content The composable content to be rendered for this item in the grid. + */ +@Stable +class MorphFabItem( + val content: @Composable () -> Unit +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/RadialFabItem.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/RadialFabItem.kt new file mode 100644 index 0000000..8477322 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/RadialFabItem.kt @@ -0,0 +1,15 @@ +package com.developerstring.jetco_kmp.components.button.fab.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import com.developerstring.jetco_kmp.components.button.fab.RadialFloatingActionButton + +/** + * A wrapper for a custom composable to be used as an item in [RadialFloatingActionButton]. + * + * @property content The composable content to be rendered for this radial item. + */ +@Stable +class RadialFabItem( + val content: @Composable () -> Unit +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/StackDirection.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/StackDirection.kt new file mode 100644 index 0000000..aa5f42c --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/StackDirection.kt @@ -0,0 +1,10 @@ +package com.developerstring.jetco_kmp.components.button.fab.model + +enum class StackDirection { + /** Spreads items upward above the main FAB. */ + TOP, + /** Spreads items to the left of the main FAB. */ + START, + /** Spreads items to the right of the main FAB. */ + END +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/StackExpandOffset.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/StackExpandOffset.kt new file mode 100644 index 0000000..d7d14f0 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/StackExpandOffset.kt @@ -0,0 +1,9 @@ +package com.developerstring.jetco_kmp.components.button.fab.model + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +data class StackExpandOffset( + val offsetY: Dp = 0.dp, + val offsetX: Dp = 0.dp +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/StackFabItem.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/StackFabItem.kt new file mode 100644 index 0000000..4819f1b --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/model/StackFabItem.kt @@ -0,0 +1,18 @@ +package com.developerstring.jetco_kmp.components.button.fab.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import com.developerstring.jetco_kmp.components.button.fab.StackFloatingActionButton + +/** + * A wrapper for a custom composable with a specific direction to be used as an item + * in [StackFloatingActionButton]. + * + * @property direction The direction in which this item will expand from the main FAB (TOP, START, END). + * @property content The composable content to be rendered for this item. + */ +@Stable +class StackFabItem( + val direction: StackDirection = StackDirection.TOP, + val content: @Composable () -> Unit +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/scope/MorphCardScope.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/scope/MorphCardScope.kt new file mode 100644 index 0000000..80f77d9 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/scope/MorphCardScope.kt @@ -0,0 +1,10 @@ +package com.developerstring.jetco_kmp.components.button.fab.scope + +import androidx.compose.runtime.Composable + +class MorphCardScope( + internal val itemsContent: @Composable () -> Unit +) { + @Composable + fun MorphItems() = itemsContent() +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/ColorTransition.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/ColorTransition.kt new file mode 100644 index 0000000..36e2e5f --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/ColorTransition.kt @@ -0,0 +1,14 @@ +package com.developerstring.jetco_kmp.components.button.fab.transition + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.ui.graphics.Color + +data class ColorTransition( + val target: Color, + val spec: AnimationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ) +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/FabButtonTransition.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/FabButtonTransition.kt new file mode 100644 index 0000000..19a99d7 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/FabButtonTransition.kt @@ -0,0 +1,100 @@ +package com.developerstring.jetco_kmp.components.button.fab.transition + +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Describes how the main FAB button animates when the component expands or collapses. + * + * Transitions can be combined using the `+` operator. + * + * ## Example: + * ```kotlin + * val buttonTransition = FabButtonTransition.Rotate(45f) + FabButtonTransition.Scale(0.8f) + * val enterTransition = FabButtonTransition.ColorTo(Color.Red) + FabButtonTransition.Rotate(45f) + * ``` + * + * @property offset Translation animation for the main button. + * @property scale Scale animation for the main button. + * @property rotation Rotation animation for the main button. + * @property color Background color animation for the main button. + * @property then Optional transition to run sequentially after this one completes. + */ +class FabButtonTransition( + val offset: OffsetTransition? = null, + val scale: ScaleTransition? = null, + val rotation: RotateTransition? = null, + val color: ColorTransition? = null, + val then: FabButtonTransition? = null +) { + operator fun plus(other: FabButtonTransition): FabButtonTransition = FabButtonTransition( + offset = other.offset ?: this.offset, + scale = other.scale ?: this.scale, + rotation = other.rotation ?: this.rotation, + color = other.color ?: this.color, + then = other.then ?: this.then + ) + + companion object { + + fun SlideTo( + x: Dp = 0.dp, + y: Dp = 0.dp, + stiffness: Float = Spring.StiffnessMediumLow, + dampingRatio: Float = Spring.DampingRatioMediumBouncy, + then: FabButtonTransition? = null + ): FabButtonTransition = FabButtonTransition( + offset = OffsetTransition( + offsetX = x, + offsetY = y, + spec = spring(dampingRatio = dampingRatio, stiffness = stiffness) + ), + then = then + ) + + fun Scale( + scale: Float, + stiffness: Float = Spring.StiffnessMediumLow, + dampingRatio: Float = Spring.DampingRatioMediumBouncy, + then: FabButtonTransition? = null + ): FabButtonTransition = FabButtonTransition( + scale = ScaleTransition( + target = scale, + spec = spring(dampingRatio = dampingRatio, stiffness = stiffness) + ), + then = then + ) + + fun Rotate( + rotation: Float, + stiffness: Float = Spring.StiffnessMediumLow, + dampingRatio: Float = Spring.DampingRatioMediumBouncy, + then: FabButtonTransition? = null + ): FabButtonTransition = FabButtonTransition( + rotation = RotateTransition( + target = rotation, + spec = spring(dampingRatio = dampingRatio, stiffness = stiffness) + ), + then = then + ) + + fun ColorTo( + color: Color, + durationMillis: Int = 300, + easing: Easing = FastOutSlowInEasing, + then: FabButtonTransition? = null + ): FabButtonTransition = FabButtonTransition( + color = ColorTransition( + target = color, + spec = tween(durationMillis = durationMillis, easing = easing) + ), + then = then + ) + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/FabItemTransition.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/FabItemTransition.kt new file mode 100644 index 0000000..4603317 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/FabItemTransition.kt @@ -0,0 +1,78 @@ +package com.developerstring.jetco_kmp.components.button.fab.transition + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.ui.unit.Dp + +/** + * Describes how an item (FAB) animates when it enters or exits the screen. + * + * Transitions can be combined using the `+` operator. + * + * ## Example: + * ```kotlin + * val myItemTransition = FabItemTransition.Slide() + FabItemTransition.Fade() + * ``` + */ +class FabItemTransition( + val offsetSpec: AnimationSpec? = null, + val alphaSpec: AnimationSpec? = null, + val scaleSpec: AnimationSpec? = null, + val rotate: RotateTransition? = null +) { + operator fun plus(other: FabItemTransition): FabItemTransition = FabItemTransition( + offsetSpec = other.offsetSpec ?: this.offsetSpec, + alphaSpec = other.alphaSpec ?: this.alphaSpec, + scaleSpec = other.scaleSpec ?: this.scaleSpec, + rotate = other.rotate ?: this.rotate + ) + + companion object { + + fun Slide( + stiffness: Float = Spring.StiffnessMedium, + dampingRatio: Float = Spring.DampingRatioMediumBouncy, + ): FabItemTransition = FabItemTransition( + offsetSpec = spring(dampingRatio = dampingRatio, stiffness = stiffness) + ) + + fun Fade( + durationMillis: Int = 300, + easing: Easing = FastOutSlowInEasing + ): FabItemTransition = FabItemTransition( + alphaSpec = tween(durationMillis, easing = easing), + ) + + fun SlideAndFade( + durationMillis: Int = 300, + easing: Easing = FastOutSlowInEasing, + stiffness: Float = Spring.StiffnessMedium, + dampingRatio: Float = Spring.DampingRatioMediumBouncy, + ): FabItemTransition = FabItemTransition( + offsetSpec = spring(dampingRatio = dampingRatio, stiffness = stiffness), + alphaSpec = tween(durationMillis, easing = easing), + ) + + fun Scale( + stiffness: Float = Spring.StiffnessMedium, + dampingRatio: Float = Spring.DampingRatioMediumBouncy, + ): FabItemTransition = FabItemTransition( + scaleSpec = spring(dampingRatio = dampingRatio, stiffness = stiffness) + ) + + fun Rotate( + target: Float, + stiffness: Float = Spring.StiffnessMedium, + dampingRatio: Float = Spring.DampingRatioMediumBouncy, + ): FabItemTransition = FabItemTransition( + rotate = RotateTransition( + target = target, + spec = spring(dampingRatio = dampingRatio, stiffness = stiffness) + ) + ) + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/OffsetTransition.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/OffsetTransition.kt new file mode 100644 index 0000000..46c72d8 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/OffsetTransition.kt @@ -0,0 +1,10 @@ +package com.developerstring.jetco_kmp.components.button.fab.transition + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.ui.unit.Dp + +data class OffsetTransition( + val offsetX: Dp, + val offsetY: Dp, + val spec: AnimationSpec +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/RotateTransition.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/RotateTransition.kt new file mode 100644 index 0000000..1595c10 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/RotateTransition.kt @@ -0,0 +1,8 @@ +package com.developerstring.jetco_kmp.components.button.fab.transition + +import androidx.compose.animation.core.AnimationSpec + +data class RotateTransition( + val target: Float, + val spec: AnimationSpec +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/ScaleTransition.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/ScaleTransition.kt new file mode 100644 index 0000000..8b5d856 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/transition/ScaleTransition.kt @@ -0,0 +1,8 @@ +package com.developerstring.jetco_kmp.components.button.fab.transition + +import androidx.compose.animation.core.AnimationSpec + +data class ScaleTransition( + val target: Float, + val spec: AnimationSpec +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/utils/AnimateOrSnap.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/utils/AnimateOrSnap.kt new file mode 100644 index 0000000..03c3bf9 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/utils/AnimateOrSnap.kt @@ -0,0 +1,19 @@ +package com.developerstring.jetco_kmp.components.button.fab.utils + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector + +internal suspend fun Animatable.animateOrSnap( + targetValue: T?, + spec: AnimationSpec?, + predicate: () -> Boolean = { true } +) { + if (targetValue == null) return + + if (spec != null) { + animateTo(targetValue, spec) + } else if (predicate.invoke()) { + snapTo(targetValue) + } +} \ No newline at end of file diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/utils/FabAnimationUtils.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/utils/FabAnimationUtils.kt new file mode 100644 index 0000000..b01fd69 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/utils/FabAnimationUtils.kt @@ -0,0 +1,136 @@ +package com.developerstring.jetco_kmp.components.button.fab.utils + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector4D +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.button.fab.model.FabMainConfig +import com.developerstring.jetco_kmp.components.button.fab.transition.FabButtonTransition +import com.developerstring.jetco_kmp.components.button.fab.transition.FabItemTransition +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +internal suspend fun animateFabItem( + expanded: Boolean, + transition: FabItemTransition, + alpha: Animatable, + scale: Animatable, + rotation: Animatable, + offsetX: Animatable? = null, + offsetY: Animatable? = null, + targetOffsetX: Dp = 0.dp, + targetOffsetY: Dp = 0.dp, +) { + coroutineScope { + offsetX?.let { + launch { + it.animateOrSnap( + targetValue = if (expanded) targetOffsetX else 0.dp, + spec = transition.offsetSpec, + predicate = { expanded } + ) + } + } + offsetY?.let { + launch { + it.animateOrSnap( + targetValue = if (expanded) targetOffsetY else 0.dp, + spec = transition.offsetSpec, + predicate = { expanded } + ) + } + } + launch { + alpha.animateOrSnap( + targetValue = if (expanded) 1f else 0f, + spec = transition.alphaSpec, + predicate = { expanded } + ) + } + launch { + scale.animateOrSnap( + targetValue = if (expanded) 1f else 0f, + spec = transition.scaleSpec, + predicate = { expanded } + ) + } + launch { + rotation.animateOrSnap( + targetValue = transition.rotate?.target, + spec = transition.rotate?.spec, + predicate = { expanded } + ) + } + } + + if (!expanded) { + offsetX?.snapTo(0.dp) + offsetY?.snapTo(0.dp) + alpha.snapTo(0f) + scale.snapTo(0f) + rotation.snapTo(0f) + } +} + +internal suspend fun animateFabButton( + btnTransition: FabButtonTransition, + fabOffsetX: Animatable, + fabOffsetY: Animatable, + fabScale: Animatable, + fabRotation: Animatable, + fabColor: Animatable? = null, +) { + coroutineScope { + launch { + fabOffsetX.animateOrSnap(btnTransition.offset?.offsetX, btnTransition.offset?.spec) + } + launch { + fabOffsetY.animateOrSnap(btnTransition.offset?.offsetY, btnTransition.offset?.spec) + } + launch { + fabScale.animateOrSnap(btnTransition.scale?.target, btnTransition.scale?.spec) + } + launch { + fabRotation.animateOrSnap(btnTransition.rotation?.target, btnTransition.rotation?.spec) + } + fabColor?.let { colorAnim -> + launch { + btnTransition.color?.let { colorAnim.animateTo(it.target, it.spec) } + } + } + } + btnTransition.then?.let { next -> + animateFabButton(next, fabOffsetX, fabOffsetY, fabScale, fabRotation, fabColor) + } +} + +internal fun FabButtonTransition.resolvedFinalOffset(): Pair { + var offsetX = 0.dp + var offsetY = 0.dp + var current: FabButtonTransition? = this + while (current != null) { + current.offset?.let { + offsetX = it.offsetX + offsetY = it.offsetY + } + current = current.then + } + return offsetX to offsetY +} + +internal fun fabItemStaggerDelay( + expanded: Boolean, + index: Int, + total: Int, + totalCount: Int, + config: FabMainConfig, +): Long { + val stepMs = 300 / totalCount.coerceAtLeast(1) + val order = if (expanded) config.animation.itemEnterOrder else config.animation.itemExitOrder + return order.delayFor( + index = index, + total = (total).coerceAtLeast(0), + stepMs = stepMs + ) +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/utils/LocalFabButtonColor.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/utils/LocalFabButtonColor.kt new file mode 100644 index 0000000..903357a --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/button/fab/utils/LocalFabButtonColor.kt @@ -0,0 +1,6 @@ +package com.developerstring.jetco_kmp.components.button.fab.utils + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color + +val LocalFabButtonColor = compositionLocalOf { Color.Unspecified } diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/DatePicker.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/DatePicker.kt new file mode 100644 index 0000000..def0aa8 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/DatePicker.kt @@ -0,0 +1,308 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.components.picker + +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.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.picker.components.PickerContainer +import com.developerstring.jetco_kmp.components.picker.components.PickerHeader +import com.developerstring.jetco_kmp.components.picker.components.PickerWheel +import com.developerstring.jetco_kmp.components.picker.config.DatePickerConfig +import com.developerstring.jetco_kmp.components.picker.config.PickerContainerConfig +import com.developerstring.jetco_kmp.components.picker.config.PickerDefaults +import com.developerstring.jetco_kmp.components.picker.config.PickerHeaderConfig +import com.developerstring.jetco_kmp.components.picker.config.WheelPickerConfig +import com.developerstring.jetco_kmp.components.picker.model.DateOrder +import com.developerstring.jetco_kmp.components.picker.model.PickerDate +import com.developerstring.jetco_kmp.components.picker.model.PickerDisplayMode + +// ━━━━━━━━━━━━━━━━━━━━━ Private Helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +private val FULL_MONTH_NAMES = listOf( + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" +) + +private val SHORT_MONTH_NAMES = listOf( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" +) + +/** + * Returns the number of days in the given [month] (1-based) of [year]. + */ +private fun daysInMonth(year: Int, month: Int): Int { + return when (month) { + 2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 + } +} + +/** + * Builds the list of month display strings based on config. + */ +private fun buildMonthItems(config: DatePickerConfig): List { + return config.customMonthNames + ?: if (config.showMonthAsNumber) { + (1..12).map { it.toString().padStart(2, '0') } + } else if (config.showShortMonths) { + SHORT_MONTH_NAMES + } else { + FULL_MONTH_NAMES + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelDatePicker (inline content) ━━━━━━━━━━━ + +/** + * An inline wheel-based date picker. + * + * Displays three wheel columns for day, month, and year. The column + * order is configurable via [dateConfig.dateOrder][DateOrder]. + * + * ### Example + * ```kotlin + * WheelDatePicker( + * startDate = PickerDate.now(), + * headerConfig = PickerDefaults.headerConfig(title = "Select Date"), + * onDoneClick = { date -> /* use date */ }, + * ) + * ``` + * + * @param modifier Modifier applied to the outer container. + * @param startDate Initially selected date. + * @param wheelConfig Configuration for the wheel scrolling behaviour. + * @param headerConfig Configuration for the header section. + * @param dateConfig Configuration for date-specific options. + * @param onDoneClick Callback when "Done" is pressed with the selected date. + * @param onDateChange Callback each time the date changes via scrolling. + * @param onCancel Callback when "Cancel" is pressed. + */ +@Composable +fun WheelDatePicker( + modifier: Modifier = Modifier, + startDate: PickerDate = PickerDate.now(), + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Date"), + dateConfig: DatePickerConfig = PickerDefaults.datePickerConfig(), + onDoneClick: (PickerDate) -> Unit = {}, + onDateChange: (PickerDate) -> Unit = {}, + onCancel: () -> Unit = {} +) { + val years = (dateConfig.yearsRange.first..dateConfig.yearsRange.last).toList() + val yearItems = years.map { it.toString() } + val monthItems = buildMonthItems(dateConfig) + + var selectedYear by remember { mutableIntStateOf(startDate.year) } + var selectedMonth by remember { mutableIntStateOf(startDate.month) } + var selectedDay by remember { mutableIntStateOf(startDate.dayOfMonth) } + + val maxDays = daysInMonth(selectedYear, selectedMonth) + val dayItems = (1..maxDays).map { it.toString().padStart(2, '0') } + + // Clamp day to valid range when month/year changes + val clampedDay = selectedDay.coerceIn(1, maxDays) + if (clampedDay != selectedDay) { + selectedDay = clampedDay + } + + val currentDate = PickerDate(selectedYear, selectedMonth, selectedDay) + + val yearInitialIndex = years.indexOf(selectedYear).coerceAtLeast(0) + val monthInitialIndex = (selectedMonth - 1).coerceIn(0, 11) + val dayInitialIndex = (selectedDay - 1).coerceIn(0, dayItems.lastIndex) + + Column(modifier = modifier.fillMaxWidth()) { + PickerHeader( + config = headerConfig, + onDone = { onDoneClick(currentDate) }, + onCancel = onCancel + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + val dayWheel: @Composable () -> Unit = { + PickerWheel( + items = dayItems, + initialIndex = dayInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + selectedDay = index + 1 + val date = PickerDate(selectedYear, selectedMonth, selectedDay) + onDateChange(date) + } + } + + val monthWheel: @Composable () -> Unit = { + PickerWheel( + items = monthItems, + initialIndex = monthInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1.2f) + ) { index -> + selectedMonth = index + 1 + // Clamp day + val newMax = daysInMonth(selectedYear, selectedMonth) + if (selectedDay > newMax) selectedDay = newMax + val date = PickerDate(selectedYear, selectedMonth, selectedDay) + onDateChange(date) + } + } + + val yearWheel: @Composable () -> Unit = { + PickerWheel( + items = yearItems, + initialIndex = yearInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + selectedYear = years[index] + // Clamp day for leap-year changes + val newMax = daysInMonth(selectedYear, selectedMonth) + if (selectedDay > newMax) selectedDay = newMax + val date = PickerDate(selectedYear, selectedMonth, selectedDay) + onDateChange(date) + } + } + + when (dateConfig.dateOrder) { + DateOrder.DAY_MONTH_YEAR -> { + dayWheel() + monthWheel() + yearWheel() + } + DateOrder.MONTH_DAY_YEAR -> { + monthWheel() + dayWheel() + yearWheel() + } + DateOrder.YEAR_MONTH_DAY -> { + yearWheel() + monthWheel() + dayWheel() + } + } + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelDatePickerView (with container) ━━━━━━━ + +/** + * A date picker displayed inside a dialog, bottom sheet, or inline. + * + * This is the primary entry point when you want a ready-to-use date + * picker with a presentation container. + * + * ### Example — Bottom Sheet + * ```kotlin + * WheelDatePickerView( + * visible = showPicker, + * displayMode = PickerDisplayMode.BOTTOM_SHEET, + * startDate = PickerDate.now(), + * onDoneClick = { date -> /* use date */ }, + * onDismiss = { showPicker = false }, + * ) + * ``` + * + * ### Example — Dialog + * ```kotlin + * WheelDatePickerView( + * visible = showPicker, + * displayMode = PickerDisplayMode.DIALOG, + * headerConfig = PickerDefaults.headerConfig( + * title = "Pick a Date", + * cancelLabel = "Cancel" + * ), + * dateConfig = PickerDefaults.datePickerConfig( + * dateOrder = DateOrder.MONTH_DAY_YEAR, + * showShortMonths = true + * ), + * onDoneClick = { date -> /* use date */ }, + * onDismiss = { showPicker = false }, + * ) + * ``` + * + * @param modifier Modifier applied to the picker content. + * @param visible Whether the picker is shown. + * @param displayMode How the picker is presented. + * @param startDate Initially selected date. + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param dateConfig Date-specific configuration. + * @param containerConfig Container visual configuration. + * @param dragHandle Custom drag handle for bottom sheet. + * @param onDoneClick Callback with selected date. + * @param onDateChange Callback on date change. + * @param onDismiss Callback on dismiss. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WheelDatePickerView( + modifier: Modifier = Modifier, + visible: Boolean = false, + displayMode: PickerDisplayMode = PickerDisplayMode.BOTTOM_SHEET, + startDate: PickerDate = PickerDate.now(), + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Date"), + dateConfig: DatePickerConfig = PickerDefaults.datePickerConfig(), + containerConfig: PickerContainerConfig = PickerDefaults.containerConfig(), + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + onDoneClick: (PickerDate) -> Unit = {}, + onDateChange: (PickerDate) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + PickerContainer( + visible = visible, + displayMode = displayMode, + containerConfig = containerConfig, + dragHandle = dragHandle, + onDismiss = onDismiss + ) { + WheelDatePicker( + modifier = modifier, + startDate = startDate, + wheelConfig = wheelConfig, + headerConfig = headerConfig, + dateConfig = dateConfig, + onDoneClick = { date -> + onDoneClick(date) + onDismiss() + }, + onDateChange = onDateChange, + onCancel = onDismiss + ) + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/DateRangePicker.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/DateRangePicker.kt new file mode 100644 index 0000000..72a2393 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/DateRangePicker.kt @@ -0,0 +1,379 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.components.picker + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +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.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.picker.components.PickerContainer +import com.developerstring.jetco_kmp.components.picker.components.PickerHeader +import com.developerstring.jetco_kmp.components.picker.components.PickerWheel +import com.developerstring.jetco_kmp.components.picker.config.DatePickerConfig +import com.developerstring.jetco_kmp.components.picker.config.DateRangePickerConfig +import com.developerstring.jetco_kmp.components.picker.config.PickerContainerConfig +import com.developerstring.jetco_kmp.components.picker.config.PickerDefaults +import com.developerstring.jetco_kmp.components.picker.config.PickerHeaderConfig +import com.developerstring.jetco_kmp.components.picker.config.WheelPickerConfig +import com.developerstring.jetco_kmp.components.picker.model.DateOrder +import com.developerstring.jetco_kmp.components.picker.model.PickerDate +import com.developerstring.jetco_kmp.components.picker.model.PickerDisplayMode + +// ━━━━━━━━━━━━━━━━━━━━━ Private Helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +private val FULL_MONTHS = listOf( + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" +) + +private val SHORT_MONTHS = listOf( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" +) + +private fun daysInMonth(year: Int, month: Int): Int { + return when (month) { + 2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 + } +} + +private fun buildMonthItems(config: DatePickerConfig): List { + return config.customMonthNames + ?: if (config.showMonthAsNumber) { + (1..12).map { it.toString().padStart(2, '0') } + } else if (config.showShortMonths) { + SHORT_MONTHS + } else { + FULL_MONTHS + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelDateRangePicker (inline) ━━━━━━━━━━━━━━ + +/** + * An inline wheel-based date range picker. + * + * Displays two date selection boxes ("From" and "To") at the top. + * Tapping a box activates the corresponding wheel for editing. + * A single set of day / month / year wheels is shared between both. + * + * ### Example + * ```kotlin + * WheelDateRangePicker( + * initialFromDate = PickerDate.now(), + * initialToDate = PickerDate(2026, 3, 15), + * onDoneClick = { fromDate, toDate -> /* use dates */ }, + * ) + * ``` + * + * @param modifier Modifier applied to the outer container. + * @param initialFromDate Initially selected "from" date. + * @param initialToDate Initially selected "to" date. + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param dateConfig Date-specific configuration. + * @param rangeConfig Date range visual configuration. + * @param onDoneClick Callback when "Done" is pressed. + * @param onFromDateChange Callback when from-date changes. + * @param onToDateChange Callback when to-date changes. + * @param onCancel Callback when "Cancel" is pressed. + */ +@Composable +fun WheelDateRangePicker( + modifier: Modifier = Modifier, + initialFromDate: PickerDate = PickerDate.now(), + initialToDate: PickerDate = PickerDate.now(), + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Date Range"), + dateConfig: DatePickerConfig = PickerDefaults.datePickerConfig(), + rangeConfig: DateRangePickerConfig = PickerDefaults.dateRangePickerConfig(), + onDoneClick: (fromDate: PickerDate, toDate: PickerDate) -> Unit = { _, _ -> }, + onFromDateChange: (PickerDate) -> Unit = {}, + onToDateChange: (PickerDate) -> Unit = {}, + onCancel: () -> Unit = {} +) { + // 0 = editing "from", 1 = editing "to" + var editingIndex by remember { mutableIntStateOf(0) } + + var fromYear by remember { mutableIntStateOf(initialFromDate.year) } + var fromMonth by remember { mutableIntStateOf(initialFromDate.month) } + var fromDay by remember { mutableIntStateOf(initialFromDate.dayOfMonth) } + + var toYear by remember { mutableIntStateOf(initialToDate.year) } + var toMonth by remember { mutableIntStateOf(initialToDate.month) } + var toDay by remember { mutableIntStateOf(initialToDate.dayOfMonth) } + + // Current editing state references + val activeYear = if (editingIndex == 0) fromYear else toYear + val activeMonth = if (editingIndex == 0) fromMonth else toMonth + val activeDay = if (editingIndex == 0) fromDay else toDay + + val years = (dateConfig.yearsRange.first..dateConfig.yearsRange.last).toList() + val yearItems = years.map { it.toString() } + val monthItems = buildMonthItems(dateConfig) + val maxDays = daysInMonth(activeYear, activeMonth) + val dayItems = (1..maxDays).map { it.toString().padStart(2, '0') } + + val fromDate = PickerDate(fromYear, fromMonth, fromDay.coerceIn(1, daysInMonth(fromYear, fromMonth))) + val toDate = PickerDate(toYear, toMonth, toDay.coerceIn(1, daysInMonth(toYear, toMonth))) + + val yearInitialIndex = years.indexOf(activeYear).coerceAtLeast(0) + val monthInitialIndex = (activeMonth - 1).coerceIn(0, 11) + val dayInitialIndex = (activeDay - 1).coerceIn(0, dayItems.lastIndex) + + Column(modifier = modifier.fillMaxWidth()) { + PickerHeader( + config = headerConfig, + onDone = { onDoneClick(fromDate, toDate) }, + onCancel = onCancel + ) + + // ── Date range boxes ── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + // From box + Surface( + modifier = Modifier + .weight(1f) + .clickable { editingIndex = 0 }, + shape = rangeConfig.boxShape, + color = if (editingIndex == 0) rangeConfig.selectedBoxColor else rangeConfig.boxColor, + border = BorderStroke( + width = 1.5.dp, + color = if (editingIndex == 0) rangeConfig.selectedBoxBorderColor else rangeConfig.boxBorderColor + ) + ) { + Text( + text = fromDate.format(rangeConfig.dateTextFormat), + style = rangeConfig.boxTextStyle, + color = if (editingIndex == 0) rangeConfig.selectedTextColor else rangeConfig.boxTextStyle.color, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 12.dp, horizontal = 8.dp) + ) + } + + // Center text + Text( + text = rangeConfig.centerText, + style = rangeConfig.boxTextStyle, + modifier = Modifier.padding(horizontal = 12.dp) + ) + + // To box + Surface( + modifier = Modifier + .weight(1f) + .clickable { editingIndex = 1 }, + shape = rangeConfig.boxShape, + color = if (editingIndex == 1) rangeConfig.selectedBoxColor else rangeConfig.boxColor, + border = BorderStroke( + width = 1.5.dp, + color = if (editingIndex == 1) rangeConfig.selectedBoxBorderColor else rangeConfig.boxBorderColor + ) + ) { + Text( + text = toDate.format(rangeConfig.dateTextFormat), + style = rangeConfig.boxTextStyle, + color = if (editingIndex == 1) rangeConfig.selectedTextColor else rangeConfig.boxTextStyle.color, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 12.dp, horizontal = 8.dp) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // ── Wheel section ── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + val dayWheel: @Composable () -> Unit = { + PickerWheel( + items = dayItems, + initialIndex = dayInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + val day = index + 1 + if (editingIndex == 0) { + fromDay = day + onFromDateChange(PickerDate(fromYear, fromMonth, fromDay)) + } else { + toDay = day + onToDateChange(PickerDate(toYear, toMonth, toDay)) + } + } + } + + val monthWheel: @Composable () -> Unit = { + PickerWheel( + items = monthItems, + initialIndex = monthInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1.2f) + ) { index -> + val month = index + 1 + if (editingIndex == 0) { + fromMonth = month + val newMax = daysInMonth(fromYear, fromMonth) + if (fromDay > newMax) fromDay = newMax + onFromDateChange(PickerDate(fromYear, fromMonth, fromDay)) + } else { + toMonth = month + val newMax = daysInMonth(toYear, toMonth) + if (toDay > newMax) toDay = newMax + onToDateChange(PickerDate(toYear, toMonth, toDay)) + } + } + } + + val yearWheel: @Composable () -> Unit = { + PickerWheel( + items = yearItems, + initialIndex = yearInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + val year = years[index] + if (editingIndex == 0) { + fromYear = year + val newMax = daysInMonth(fromYear, fromMonth) + if (fromDay > newMax) fromDay = newMax + onFromDateChange(PickerDate(fromYear, fromMonth, fromDay)) + } else { + toYear = year + val newMax = daysInMonth(toYear, toMonth) + if (toDay > newMax) toDay = newMax + onToDateChange(PickerDate(toYear, toMonth, toDay)) + } + } + } + + when (dateConfig.dateOrder) { + DateOrder.DAY_MONTH_YEAR -> { dayWheel(); monthWheel(); yearWheel() } + DateOrder.MONTH_DAY_YEAR -> { monthWheel(); dayWheel(); yearWheel() } + DateOrder.YEAR_MONTH_DAY -> { yearWheel(); monthWheel(); dayWheel() } + } + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelDateRangePickerView (with container) ━━ + +/** + * A date range picker displayed inside a dialog, bottom sheet, or inline. + * + * ### Example — Bottom Sheet + * ```kotlin + * WheelDateRangePickerView( + * visible = showRangePicker, + * displayMode = PickerDisplayMode.BOTTOM_SHEET, + * initialFromDate = PickerDate.now(), + * initialToDate = PickerDate(2026, 12, 31), + * onDoneClick = { from, to -> /* use dates */ }, + * onDismiss = { showRangePicker = false }, + * ) + * ``` + * + * @param modifier Modifier applied to the picker content. + * @param visible Whether the picker is shown. + * @param displayMode How the picker is presented. + * @param initialFromDate Initially selected "from" date. + * @param initialToDate Initially selected "to" date. + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param dateConfig Date-specific configuration. + * @param rangeConfig Date range visual configuration. + * @param containerConfig Container visual configuration. + * @param dragHandle Custom drag handle for bottom sheet. + * @param onDoneClick Callback with from and to dates. + * @param onFromDateChange Callback when from-date changes. + * @param onToDateChange Callback when to-date changes. + * @param onDismiss Callback on dismiss. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WheelDateRangePickerView( + modifier: Modifier = Modifier, + visible: Boolean = false, + displayMode: PickerDisplayMode = PickerDisplayMode.BOTTOM_SHEET, + initialFromDate: PickerDate = PickerDate.now(), + initialToDate: PickerDate = PickerDate.now(), + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Date Range"), + dateConfig: DatePickerConfig = PickerDefaults.datePickerConfig(), + rangeConfig: DateRangePickerConfig = PickerDefaults.dateRangePickerConfig(), + containerConfig: PickerContainerConfig = PickerDefaults.containerConfig(), + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + onDoneClick: (fromDate: PickerDate, toDate: PickerDate) -> Unit = { _, _ -> }, + onFromDateChange: (PickerDate) -> Unit = {}, + onToDateChange: (PickerDate) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + PickerContainer( + visible = visible, + displayMode = displayMode, + containerConfig = containerConfig, + dragHandle = dragHandle, + onDismiss = onDismiss + ) { + WheelDateRangePicker( + modifier = modifier, + initialFromDate = initialFromDate, + initialToDate = initialToDate, + wheelConfig = wheelConfig, + headerConfig = headerConfig, + dateConfig = dateConfig, + rangeConfig = rangeConfig, + onDoneClick = { from, to -> + onDoneClick(from, to) + onDismiss() + }, + onFromDateChange = onFromDateChange, + onToDateChange = onToDateChange, + onCancel = onDismiss + ) + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/DateTimePicker.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/DateTimePicker.kt new file mode 100644 index 0000000..07550c5 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/DateTimePicker.kt @@ -0,0 +1,405 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.components.picker + +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.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.picker.components.PickerContainer +import com.developerstring.jetco_kmp.components.picker.components.PickerHeader +import com.developerstring.jetco_kmp.components.picker.components.PickerWheel +import com.developerstring.jetco_kmp.components.picker.config.DatePickerConfig +import com.developerstring.jetco_kmp.components.picker.config.PickerContainerConfig +import com.developerstring.jetco_kmp.components.picker.config.PickerDefaults +import com.developerstring.jetco_kmp.components.picker.config.PickerHeaderConfig +import com.developerstring.jetco_kmp.components.picker.config.TimePickerConfig +import com.developerstring.jetco_kmp.components.picker.config.WheelPickerConfig +import com.developerstring.jetco_kmp.components.picker.model.DateOrder +import com.developerstring.jetco_kmp.components.picker.model.PickerDate +import com.developerstring.jetco_kmp.components.picker.model.PickerDateTime +import com.developerstring.jetco_kmp.components.picker.model.PickerDisplayMode +import com.developerstring.jetco_kmp.components.picker.model.PickerTime +import com.developerstring.jetco_kmp.components.picker.model.TimeFormat + +// ━━━━━━━━━━━━━━━━━━━━━ Private Helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +private val FULL_MONTHS = listOf( + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" +) + +private val SHORT_MONTHS = listOf( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" +) + +private fun daysInMonth(year: Int, month: Int): Int { + return when (month) { + 2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 + } +} + +private fun buildMonthItems(config: DatePickerConfig): List { + return config.customMonthNames + ?: if (config.showMonthAsNumber) { + (1..12).map { it.toString().padStart(2, '0') } + } else if (config.showShortMonths) { + SHORT_MONTHS + } else { + FULL_MONTHS + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelDateTimePicker (inline) ━━━━━━━━━━━━━━━ + +/** + * An inline wheel-based date **and** time picker. + * + * Combines a date wheel section (day / month / year) with a time + * wheel section (hour / minute / optional seconds / optional AM-PM) + * in a single picker, separated by a thin divider. + * + * ### Example + * ```kotlin + * WheelDateTimePicker( + * startDateTime = PickerDateTime.now(), + * onDoneClick = { dateTime -> /* use dateTime */ }, + * ) + * ``` + * + * @param modifier Modifier applied to the outer container. + * @param startDateTime Initially selected date and time. + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param dateConfig Date-specific configuration. + * @param timeConfig Time-specific configuration. + * @param dividerColor Color of the divider between date and time. + * @param onDoneClick Callback when "Done" is pressed. + * @param onDateTimeChange Callback each time the date or time changes. + * @param onCancel Callback when "Cancel" is pressed. + */ +@Composable +fun WheelDateTimePicker( + modifier: Modifier = Modifier, + startDateTime: PickerDateTime = PickerDateTime.now(), + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Date & Time"), + dateConfig: DatePickerConfig = PickerDefaults.datePickerConfig(), + timeConfig: TimePickerConfig = PickerDefaults.timePickerConfig(), + dividerColor: Color = Color(0xFFE0E0E0), + onDoneClick: (PickerDateTime) -> Unit = {}, + onDateTimeChange: (PickerDateTime) -> Unit = {}, + onCancel: () -> Unit = {} +) { + val startDate = startDateTime.date + val startTime = startDateTime.time + + val is12Hour = timeConfig.timeFormat == TimeFormat.HOUR_12 + val minuteInterval = timeConfig.minuteInterval.coerceIn(1, 30) + val secondInterval = timeConfig.secondInterval.coerceIn(1, 30) + + // ── Date state ── + val years = (dateConfig.yearsRange.first..dateConfig.yearsRange.last).toList() + val yearItems = years.map { it.toString() } + val monthItems = buildMonthItems(dateConfig) + + var selectedYear by remember { mutableIntStateOf(startDate.year) } + var selectedMonth by remember { mutableIntStateOf(startDate.month) } + var selectedDay by remember { mutableIntStateOf(startDate.dayOfMonth) } + + val maxDays = daysInMonth(selectedYear, selectedMonth) + val dayItems = (1..maxDays).map { it.toString().padStart(2, '0') } + val clampedDay = selectedDay.coerceIn(1, maxDays) + if (clampedDay != selectedDay) selectedDay = clampedDay + + // ── Time state ── + val hourRange = if (is12Hour) (1..12) else (0..23) + val hourItems = hourRange.map { it.toString().padStart(2, '0') } + val minuteValues = (0..59).filter { it % minuteInterval == 0 } + val minuteItems = minuteValues.map { it.toString().padStart(2, '0') } + val secondValues = (0..59).filter { it % secondInterval == 0 } + val secondItems = secondValues.map { it.toString().padStart(2, '0') } + val amPmItems = listOf("AM", "PM") + + var selectedHour by remember { mutableIntStateOf(startTime.hour) } + var selectedMinute by remember { mutableIntStateOf(startTime.minute) } + var selectedSecond by remember { mutableIntStateOf(startTime.second) } + var selectedAmPm by remember { mutableIntStateOf(if (startTime.hour >= 12) 1 else 0) } + + fun get24Hour(): Int { + return if (is12Hour) { + val h = if (selectedHour == 0) 12 else selectedHour + when { + selectedAmPm == 0 && h == 12 -> 0 + selectedAmPm == 1 && h == 12 -> 12 + selectedAmPm == 1 -> h + 12 + else -> h + } + } else selectedHour + } + + val clampedMinute = minuteValues.minByOrNull { kotlin.math.abs(it - selectedMinute) } ?: 0 + val clampedSecond = secondValues.minByOrNull { kotlin.math.abs(it - selectedSecond) } ?: 0 + + fun currentDateTime(): PickerDateTime = PickerDateTime( + date = PickerDate(selectedYear, selectedMonth, selectedDay), + time = PickerTime( + hour = get24Hour(), + minute = clampedMinute, + second = if (timeConfig.showSeconds) clampedSecond else 0 + ) + ) + + // ── Initial indices ── + val yearInitialIndex = years.indexOf(selectedYear).coerceAtLeast(0) + val monthInitialIndex = (selectedMonth - 1).coerceIn(0, 11) + val dayInitialIndex = (selectedDay - 1).coerceIn(0, dayItems.lastIndex) + + val hourInitialIndex = if (is12Hour) { + val display12 = if (startTime.hour % 12 == 0) 12 else startTime.hour % 12 + hourRange.indexOf(display12).coerceAtLeast(0) + } else { + hourRange.indexOf(startTime.hour).coerceAtLeast(0) + } + val minuteInitialIndex = minuteValues.indexOf( + minuteValues.minByOrNull { kotlin.math.abs(it - startTime.minute) } ?: 0 + ).coerceAtLeast(0) + val secondInitialIndex = secondValues.indexOf( + secondValues.minByOrNull { kotlin.math.abs(it - startTime.second) } ?: 0 + ).coerceAtLeast(0) + val amPmInitialIndex = if (startTime.hour >= 12) 1 else 0 + + Column(modifier = modifier.fillMaxWidth()) { + PickerHeader( + config = headerConfig, + onDone = { onDoneClick(currentDateTime()) }, + onCancel = onCancel + ) + + // ── Date section ── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + val dayWheel: @Composable () -> Unit = { + PickerWheel( + items = dayItems, + initialIndex = dayInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + selectedDay = index + 1 + onDateTimeChange(currentDateTime()) + } + } + + val monthWheel: @Composable () -> Unit = { + PickerWheel( + items = monthItems, + initialIndex = monthInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1.2f) + ) { index -> + selectedMonth = index + 1 + val newMax = daysInMonth(selectedYear, selectedMonth) + if (selectedDay > newMax) selectedDay = newMax + onDateTimeChange(currentDateTime()) + } + } + + val yearWheel: @Composable () -> Unit = { + PickerWheel( + items = yearItems, + initialIndex = yearInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + selectedYear = years[index] + val newMax = daysInMonth(selectedYear, selectedMonth) + if (selectedDay > newMax) selectedDay = newMax + onDateTimeChange(currentDateTime()) + } + } + + when (dateConfig.dateOrder) { + DateOrder.DAY_MONTH_YEAR -> { dayWheel(); monthWheel(); yearWheel() } + DateOrder.MONTH_DAY_YEAR -> { monthWheel(); dayWheel(); yearWheel() } + DateOrder.YEAR_MONTH_DAY -> { yearWheel(); monthWheel(); dayWheel() } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider( + color = dividerColor, + modifier = Modifier.padding(horizontal = 24.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + + // ── Time section ── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + PickerWheel( + items = hourItems, + initialIndex = hourInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + selectedHour = hourRange.toList()[index] + onDateTimeChange(currentDateTime()) + } + + PickerWheel( + items = minuteItems, + initialIndex = minuteInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + selectedMinute = minuteValues[index] + onDateTimeChange(currentDateTime()) + } + + if (timeConfig.showSeconds) { + PickerWheel( + items = secondItems, + initialIndex = secondInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + selectedSecond = secondValues[index] + onDateTimeChange(currentDateTime()) + } + } + + if (is12Hour) { + PickerWheel( + items = amPmItems, + initialIndex = amPmInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(0.8f) + ) { index -> + selectedAmPm = index + onDateTimeChange(currentDateTime()) + } + } + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelDateTimePickerView (with container) ━━━ + +/** + * A combined date & time picker displayed inside a dialog, bottom sheet, + * or inline. + * + * ### Example — Dialog + * ```kotlin + * WheelDateTimePickerView( + * visible = showPicker, + * displayMode = PickerDisplayMode.DIALOG, + * startDateTime = PickerDateTime.now(), + * headerConfig = PickerDefaults.headerConfig( + * title = "Pick Date & Time", + * cancelLabel = "Cancel" + * ), + * timeConfig = PickerDefaults.timePickerConfig( + * timeFormat = TimeFormat.HOUR_12 + * ), + * onDoneClick = { dateTime -> /* use dateTime */ }, + * onDismiss = { showPicker = false }, + * ) + * ``` + * + * @param modifier Modifier applied to the picker content. + * @param visible Whether the picker is shown. + * @param displayMode How the picker is presented. + * @param startDateTime Initially selected date and time. + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param dateConfig Date-specific configuration. + * @param timeConfig Time-specific configuration. + * @param dividerColor Divider color between date/time sections. + * @param containerConfig Container visual configuration. + * @param dragHandle Custom drag handle for bottom sheet. + * @param onDoneClick Callback with selected date and time. + * @param onDateTimeChange Callback on datetime change. + * @param onDismiss Callback on dismiss. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WheelDateTimePickerView( + modifier: Modifier = Modifier, + visible: Boolean = false, + displayMode: PickerDisplayMode = PickerDisplayMode.BOTTOM_SHEET, + startDateTime: PickerDateTime = PickerDateTime.now(), + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Date & Time"), + dateConfig: DatePickerConfig = PickerDefaults.datePickerConfig(), + timeConfig: TimePickerConfig = PickerDefaults.timePickerConfig(), + dividerColor: Color = Color(0xFFE0E0E0), + containerConfig: PickerContainerConfig = PickerDefaults.containerConfig(), + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + onDoneClick: (PickerDateTime) -> Unit = {}, + onDateTimeChange: (PickerDateTime) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + PickerContainer( + visible = visible, + displayMode = displayMode, + containerConfig = containerConfig, + dragHandle = dragHandle, + onDismiss = onDismiss + ) { + WheelDateTimePicker( + modifier = modifier, + startDateTime = startDateTime, + wheelConfig = wheelConfig, + headerConfig = headerConfig, + dateConfig = dateConfig, + timeConfig = timeConfig, + dividerColor = dividerColor, + onDoneClick = { dateTime -> + onDoneClick(dateTime) + onDismiss() + }, + onDateTimeChange = onDateTimeChange, + onCancel = onDismiss + ) + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/MonthYearPicker.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/MonthYearPicker.kt new file mode 100644 index 0000000..0ef9fc9 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/MonthYearPicker.kt @@ -0,0 +1,495 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.components.picker + +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.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.picker.components.PickerContainer +import com.developerstring.jetco_kmp.components.picker.components.PickerHeader +import com.developerstring.jetco_kmp.components.picker.components.PickerWheel +import com.developerstring.jetco_kmp.components.picker.config.DatePickerConfig +import com.developerstring.jetco_kmp.components.picker.config.PickerContainerConfig +import com.developerstring.jetco_kmp.components.picker.config.PickerDefaults +import com.developerstring.jetco_kmp.components.picker.config.PickerHeaderConfig +import com.developerstring.jetco_kmp.components.picker.config.WheelPickerConfig +import com.developerstring.jetco_kmp.components.picker.model.PickerDisplayMode +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +// ━━━━━━━━━━━━━━━━━━━━━ Private Helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +private val FULL_MONTHS = listOf( + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" +) + +private val SHORT_MONTHS = listOf( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" +) + +private fun buildMonthItems(config: DatePickerConfig): List { + return config.customMonthNames + ?: if (config.showMonthAsNumber) { + (1..12).map { it.toString().padStart(2, '0') } + } else if (config.showShortMonths) { + SHORT_MONTHS + } else { + FULL_MONTHS + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelMonthYearPicker (inline) ━━━━━━━━━━━━━━ + +/** + * An inline wheel-based month & year picker. + * + * Only displays month and year columns — the day component is omitted. + * Useful for credit card expiry, billing period, or report filters. + * + * ### Example + * ```kotlin + * WheelMonthYearPicker( + * startMonth = 6, + * startYear = 2026, + * onDoneClick = { month, year -> /* use values */ }, + * ) + * ``` + * + * @param modifier Modifier applied to the outer container. + * @param startMonth Initially selected month (1–12). + * @param startYear Initially selected year. + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param dateConfig Date-specific configuration (month names, years range). + * @param onDoneClick Callback when "Done" is pressed. + * @param onSelectionChange Callback when month or year changes. + * @param onCancel Callback when "Cancel" is pressed. + */ +@Composable +fun WheelMonthYearPicker( + modifier: Modifier = Modifier, + startMonth: Int = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).monthNumber, + startYear: Int = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year, + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Month & Year"), + dateConfig: DatePickerConfig = PickerDefaults.datePickerConfig(), + onDoneClick: (month: Int, year: Int) -> Unit = { _, _ -> }, + onSelectionChange: (month: Int, year: Int) -> Unit = { _, _ -> }, + onCancel: () -> Unit = {} +) { + val years = (dateConfig.yearsRange.first..dateConfig.yearsRange.last).toList() + val yearItems = years.map { it.toString() } + val monthItems = buildMonthItems(dateConfig) + + var selectedMonth by remember { mutableIntStateOf(startMonth) } + var selectedYear by remember { mutableIntStateOf(startYear) } + + val yearInitialIndex = years.indexOf(selectedYear).coerceAtLeast(0) + val monthInitialIndex = (selectedMonth - 1).coerceIn(0, 11) + + Column(modifier = modifier.fillMaxWidth()) { + PickerHeader( + config = headerConfig, + onDone = { onDoneClick(selectedMonth, selectedYear) }, + onCancel = onCancel + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + PickerWheel( + items = monthItems, + initialIndex = monthInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1.2f) + ) { index -> + selectedMonth = index + 1 + onSelectionChange(selectedMonth, selectedYear) + } + + PickerWheel( + items = yearItems, + initialIndex = yearInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + selectedYear = years[index] + onSelectionChange(selectedMonth, selectedYear) + } + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelMonthYearPickerView (with container) ━━ + +/** + * A month & year picker displayed inside a dialog, bottom sheet, or inline. + * + * ### Example — Dialog + * ```kotlin + * WheelMonthYearPickerView( + * visible = showPicker, + * displayMode = PickerDisplayMode.DIALOG, + * onDoneClick = { month, year -> /* use values */ }, + * onDismiss = { showPicker = false }, + * ) + * ``` + * + * @param modifier Modifier applied to the picker content. + * @param visible Whether the picker is shown. + * @param displayMode How the picker is presented. + * @param startMonth Initially selected month (1–12). + * @param startYear Initially selected year. + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param dateConfig Date-specific configuration. + * @param containerConfig Container visual configuration. + * @param dragHandle Custom drag handle for bottom sheet. + * @param onDoneClick Callback with selected month and year. + * @param onSelectionChange Callback on selection change. + * @param onDismiss Callback on dismiss. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WheelMonthYearPickerView( + modifier: Modifier = Modifier, + visible: Boolean = false, + displayMode: PickerDisplayMode = PickerDisplayMode.BOTTOM_SHEET, + startMonth: Int = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).monthNumber, + startYear: Int = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year, + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Month & Year"), + dateConfig: DatePickerConfig = PickerDefaults.datePickerConfig(), + containerConfig: PickerContainerConfig = PickerDefaults.containerConfig(), + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + onDoneClick: (month: Int, year: Int) -> Unit = { _, _ -> }, + onSelectionChange: (month: Int, year: Int) -> Unit = { _, _ -> }, + onDismiss: () -> Unit = {} +) { + PickerContainer( + visible = visible, + displayMode = displayMode, + containerConfig = containerConfig, + dragHandle = dragHandle, + onDismiss = onDismiss + ) { + WheelMonthYearPicker( + modifier = modifier, + startMonth = startMonth, + startYear = startYear, + wheelConfig = wheelConfig, + headerConfig = headerConfig, + dateConfig = dateConfig, + onDoneClick = { month, year -> + onDoneClick(month, year) + onDismiss() + }, + onSelectionChange = onSelectionChange, + onCancel = onDismiss + ) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelYearPicker (inline) ━━━━━━━━━━━━━━━━━━━ + +/** + * An inline wheel-based year-only picker. + * + * Displays a single year column. Useful for fiscal year selection, + * birth year, or graduation year. + * + * ### Example + * ```kotlin + * WheelYearPicker( + * startYear = 2026, + * yearsRange = IntRange(2000, 2050), + * onDoneClick = { year -> /* use year */ }, + * ) + * ``` + * + * @param modifier Modifier applied to the outer container. + * @param startYear Initially selected year. + * @param yearsRange Allowed year range. + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param onDoneClick Callback when "Done" is pressed. + * @param onYearChange Callback when the year changes. + * @param onCancel Callback when "Cancel" is pressed. + */ +@Composable +fun WheelYearPicker( + modifier: Modifier = Modifier, + startYear: Int = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year, + yearsRange: IntRange = IntRange(1922, 2122), + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Year"), + onDoneClick: (year: Int) -> Unit = {}, + onYearChange: (year: Int) -> Unit = {}, + onCancel: () -> Unit = {} +) { + val years = (yearsRange.first..yearsRange.last).toList() + val yearItems = years.map { it.toString() } + + var selectedYear by remember { mutableIntStateOf(startYear) } + val yearInitialIndex = years.indexOf(selectedYear).coerceAtLeast(0) + + Column(modifier = modifier.fillMaxWidth()) { + PickerHeader( + config = headerConfig, + onDone = { onDoneClick(selectedYear) }, + onCancel = onCancel + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 64.dp), + horizontalArrangement = Arrangement.Center + ) { + PickerWheel( + items = yearItems, + initialIndex = yearInitialIndex, + config = wheelConfig, + modifier = Modifier.fillMaxWidth() + ) { index -> + selectedYear = years[index] + onYearChange(selectedYear) + } + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelYearPickerView (with container) ━━━━━━━ + +/** + * A year-only picker displayed inside a dialog, bottom sheet, or inline. + * + * ### Example — Bottom Sheet + * ```kotlin + * WheelYearPickerView( + * visible = showYearPicker, + * displayMode = PickerDisplayMode.BOTTOM_SHEET, + * startYear = 2026, + * onDoneClick = { year -> /* use year */ }, + * onDismiss = { showYearPicker = false }, + * ) + * ``` + * + * @param modifier Modifier applied to the picker content. + * @param visible Whether the picker is shown. + * @param displayMode How the picker is presented. + * @param startYear Initially selected year. + * @param yearsRange Allowed year range. + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param containerConfig Container visual configuration. + * @param dragHandle Custom drag handle for bottom sheet. + * @param onDoneClick Callback with selected year. + * @param onYearChange Callback on year change. + * @param onDismiss Callback on dismiss. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WheelYearPickerView( + modifier: Modifier = Modifier, + visible: Boolean = false, + displayMode: PickerDisplayMode = PickerDisplayMode.BOTTOM_SHEET, + startYear: Int = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year, + yearsRange: IntRange = IntRange(1922, 2122), + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Year"), + containerConfig: PickerContainerConfig = PickerDefaults.containerConfig(), + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + onDoneClick: (year: Int) -> Unit = {}, + onYearChange: (year: Int) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + PickerContainer( + visible = visible, + displayMode = displayMode, + containerConfig = containerConfig, + dragHandle = dragHandle, + onDismiss = onDismiss + ) { + WheelYearPicker( + modifier = modifier, + startYear = startYear, + yearsRange = yearsRange, + wheelConfig = wheelConfig, + headerConfig = headerConfig, + onDoneClick = { year -> + onDoneClick(year) + onDismiss() + }, + onYearChange = onYearChange, + onCancel = onDismiss + ) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelMonthPicker (inline) ━━━━━━━━━━━━━━━━━━ + +/** + * An inline wheel-based month-only picker. + * + * Displays a single month column. Useful for subscription period, + * birthday month, or report month selection. + * + * ### Example + * ```kotlin + * WheelMonthPicker( + * startMonth = 3, + * showShortMonths = true, + * onDoneClick = { month -> /* use month (1-12) */ }, + * ) + * ``` + * + * @param modifier Modifier applied to the outer container. + * @param startMonth Initially selected month (1–12). + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param dateConfig Date-specific configuration (for month display names). + * @param onDoneClick Callback when "Done" is pressed. + * @param onMonthChange Callback when the month changes. + * @param onCancel Callback when "Cancel" is pressed. + */ +@Composable +fun WheelMonthPicker( + modifier: Modifier = Modifier, + startMonth: Int = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).monthNumber, + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Month"), + dateConfig: DatePickerConfig = PickerDefaults.datePickerConfig(), + onDoneClick: (month: Int) -> Unit = {}, + onMonthChange: (month: Int) -> Unit = {}, + onCancel: () -> Unit = {} +) { + val monthItems = buildMonthItems(dateConfig) + + var selectedMonth by remember { mutableIntStateOf(startMonth) } + val monthInitialIndex = (selectedMonth - 1).coerceIn(0, 11) + + Column(modifier = modifier.fillMaxWidth()) { + PickerHeader( + config = headerConfig, + onDone = { onDoneClick(selectedMonth) }, + onCancel = onCancel + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 64.dp), + horizontalArrangement = Arrangement.Center + ) { + PickerWheel( + items = monthItems, + initialIndex = monthInitialIndex, + config = wheelConfig, + modifier = Modifier.fillMaxWidth() + ) { index -> + selectedMonth = index + 1 + onMonthChange(selectedMonth) + } + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelMonthPickerView (with container) ━━━━━━ + +/** + * A month-only picker displayed inside a dialog, bottom sheet, or inline. + * + * ### Example — Dialog + * ```kotlin + * WheelMonthPickerView( + * visible = showMonthPicker, + * displayMode = PickerDisplayMode.DIALOG, + * dateConfig = PickerDefaults.datePickerConfig(showShortMonths = true), + * onDoneClick = { month -> /* 1-12 */ }, + * onDismiss = { showMonthPicker = false }, + * ) + * ``` + * + * @param modifier Modifier applied to the picker content. + * @param visible Whether the picker is shown. + * @param displayMode How the picker is presented. + * @param startMonth Initially selected month (1–12). + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param dateConfig Date-specific configuration. + * @param containerConfig Container visual configuration. + * @param dragHandle Custom drag handle for bottom sheet. + * @param onDoneClick Callback with selected month (1–12). + * @param onMonthChange Callback on month change. + * @param onDismiss Callback on dismiss. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WheelMonthPickerView( + modifier: Modifier = Modifier, + visible: Boolean = false, + displayMode: PickerDisplayMode = PickerDisplayMode.BOTTOM_SHEET, + startMonth: Int = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).monthNumber, + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Month"), + dateConfig: DatePickerConfig = PickerDefaults.datePickerConfig(), + containerConfig: PickerContainerConfig = PickerDefaults.containerConfig(), + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + onDoneClick: (month: Int) -> Unit = {}, + onMonthChange: (month: Int) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + PickerContainer( + visible = visible, + displayMode = displayMode, + containerConfig = containerConfig, + dragHandle = dragHandle, + onDismiss = onDismiss + ) { + WheelMonthPicker( + modifier = modifier, + startMonth = startMonth, + wheelConfig = wheelConfig, + headerConfig = headerConfig, + dateConfig = dateConfig, + onDoneClick = { month -> + onDoneClick(month) + onDismiss() + }, + onMonthChange = onMonthChange, + onCancel = onDismiss + ) + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/TimePicker.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/TimePicker.kt new file mode 100644 index 0000000..3e1fe94 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/TimePicker.kt @@ -0,0 +1,313 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.components.picker + +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.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.picker.components.PickerContainer +import com.developerstring.jetco_kmp.components.picker.components.PickerHeader +import com.developerstring.jetco_kmp.components.picker.components.PickerWheel +import com.developerstring.jetco_kmp.components.picker.config.PickerContainerConfig +import com.developerstring.jetco_kmp.components.picker.config.PickerDefaults +import com.developerstring.jetco_kmp.components.picker.config.PickerHeaderConfig +import com.developerstring.jetco_kmp.components.picker.config.TimePickerConfig +import com.developerstring.jetco_kmp.components.picker.config.WheelPickerConfig +import com.developerstring.jetco_kmp.components.picker.model.PickerDisplayMode +import com.developerstring.jetco_kmp.components.picker.model.PickerTime +import com.developerstring.jetco_kmp.components.picker.model.TimeFormat + +// ━━━━━━━━━━━━━━━━━━━━━ WheelTimePicker (inline content) ━━━━━━━━━━━ + +/** + * An inline wheel-based time picker. + * + * Displays columns for hour, minute, optional seconds, and AM/PM + * (for 12-hour format). Minute and second intervals are configurable + * to allow step-based selection (e.g. every 5 minutes). + * + * ### Example + * ```kotlin + * WheelTimePicker( + * startTime = PickerTime.now(), + * timeConfig = PickerDefaults.timePickerConfig( + * timeFormat = TimeFormat.HOUR_12, + * showSeconds = false + * ), + * onDoneClick = { time -> /* use time */ }, + * ) + * ``` + * + * @param modifier Modifier applied to the outer container. + * @param startTime Initially selected time. + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param timeConfig Time-specific configuration. + * @param onDoneClick Callback when "Done" is pressed with the selected time. + * @param onTimeChange Callback each time the time changes. + * @param onCancel Callback when "Cancel" is pressed. + */ +@Composable +fun WheelTimePicker( + modifier: Modifier = Modifier, + startTime: PickerTime = PickerTime.now(), + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Time"), + timeConfig: TimePickerConfig = PickerDefaults.timePickerConfig(), + onDoneClick: (PickerTime) -> Unit = {}, + onTimeChange: (PickerTime) -> Unit = {}, + onCancel: () -> Unit = {} +) { + val is12Hour = timeConfig.timeFormat == TimeFormat.HOUR_12 + val minuteInterval = timeConfig.minuteInterval.coerceIn(1, 30) + val secondInterval = timeConfig.secondInterval.coerceIn(1, 30) + + // Build hour items + val hourRange = if (is12Hour) (1..12) else (0..23) + val hourItems = hourRange.map { it.toString().padStart(2, '0') } + + // Build minute items + val minuteValues = (0..59).filter { it % minuteInterval == 0 } + val minuteItems = minuteValues.map { it.toString().padStart(2, '0') } + + // Build second items + val secondValues = (0..59).filter { it % secondInterval == 0 } + val secondItems = secondValues.map { it.toString().padStart(2, '0') } + + // AM/PM items + val amPmItems = listOf("AM", "PM") + + // State + var selectedHour by remember { mutableIntStateOf(startTime.hour) } + var selectedMinute by remember { mutableIntStateOf(startTime.minute) } + var selectedSecond by remember { mutableIntStateOf(startTime.second) } + var selectedAmPm by remember { + mutableIntStateOf(if (startTime.hour >= 12) 1 else 0) // 0 = AM, 1 = PM + } + + // Convert display hour to 24-hour value + fun get24Hour(): Int { + return if (is12Hour) { + val h = if (selectedHour == 0) 12 else selectedHour + when { + selectedAmPm == 0 && h == 12 -> 0 // 12 AM = 0 + selectedAmPm == 1 && h == 12 -> 12 // 12 PM = 12 + selectedAmPm == 1 -> h + 12 // PM + else -> h // AM + } + } else { + selectedHour + } + } + + // Nearest valid minute/second + val clampedMinute = minuteValues.minByOrNull { + kotlin.math.abs(it - selectedMinute) + } ?: 0 + val clampedSecond = secondValues.minByOrNull { + kotlin.math.abs(it - selectedSecond) + } ?: 0 + + val currentTime = PickerTime( + hour = get24Hour(), + minute = clampedMinute, + second = if (timeConfig.showSeconds) clampedSecond else 0 + ) + + // Initial indices + val hourInitialIndex = if (is12Hour) { + val display12 = if (startTime.hour % 12 == 0) 12 else startTime.hour % 12 + hourRange.indexOf(display12).coerceAtLeast(0) + } else { + hourRange.indexOf(startTime.hour).coerceAtLeast(0) + } + + val minuteInitialIndex = minuteValues.indexOf( + minuteValues.minByOrNull { kotlin.math.abs(it - startTime.minute) } ?: 0 + ).coerceAtLeast(0) + + val secondInitialIndex = secondValues.indexOf( + secondValues.minByOrNull { kotlin.math.abs(it - startTime.second) } ?: 0 + ).coerceAtLeast(0) + + val amPmInitialIndex = if (startTime.hour >= 12) 1 else 0 + + Column(modifier = modifier.fillMaxWidth()) { + PickerHeader( + config = headerConfig, + onDone = { onDoneClick(currentTime) }, + onCancel = onCancel + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + // Hour wheel + PickerWheel( + items = hourItems, + initialIndex = hourInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + selectedHour = hourRange.toList()[index] + onTimeChange( + PickerTime(get24Hour(), clampedMinute, if (timeConfig.showSeconds) clampedSecond else 0) + ) + } + + // Minute wheel + PickerWheel( + items = minuteItems, + initialIndex = minuteInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + selectedMinute = minuteValues[index] + onTimeChange( + PickerTime(get24Hour(), selectedMinute, if (timeConfig.showSeconds) clampedSecond else 0) + ) + } + + // Seconds wheel (optional) + if (timeConfig.showSeconds) { + PickerWheel( + items = secondItems, + initialIndex = secondInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(1f) + ) { index -> + selectedSecond = secondValues[index] + onTimeChange( + PickerTime(get24Hour(), clampedMinute, selectedSecond) + ) + } + } + + // AM/PM wheel (12-hour only) + if (is12Hour) { + PickerWheel( + items = amPmItems, + initialIndex = amPmInitialIndex, + config = wheelConfig, + modifier = Modifier.weight(0.8f) + ) { index -> + selectedAmPm = index + onTimeChange( + PickerTime(get24Hour(), clampedMinute, if (timeConfig.showSeconds) clampedSecond else 0) + ) + } + } + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━ WheelTimePickerView (with container) ━━━━━━━ + +/** + * A time picker displayed inside a dialog, bottom sheet, or inline. + * + * ### Example — Bottom Sheet + * ```kotlin + * WheelTimePickerView( + * visible = showTimePicker, + * displayMode = PickerDisplayMode.BOTTOM_SHEET, + * startTime = PickerTime.now(), + * onDoneClick = { time -> /* use time */ }, + * onDismiss = { showTimePicker = false }, + * ) + * ``` + * + * ### Example — Dialog with 12-hour format + * ```kotlin + * WheelTimePickerView( + * visible = showTimePicker, + * displayMode = PickerDisplayMode.DIALOG, + * timeConfig = PickerDefaults.timePickerConfig( + * timeFormat = TimeFormat.HOUR_12, + * showSeconds = true, + * minuteInterval = 5 + * ), + * onDoneClick = { time -> /* use time */ }, + * onDismiss = { showTimePicker = false }, + * ) + * ``` + * + * @param modifier Modifier applied to the picker content. + * @param visible Whether the picker is shown. + * @param displayMode How the picker is presented. + * @param startTime Initially selected time. + * @param wheelConfig Wheel scrolling configuration. + * @param headerConfig Header configuration. + * @param timeConfig Time-specific configuration. + * @param containerConfig Container visual configuration. + * @param dragHandle Custom drag handle for bottom sheet. + * @param onDoneClick Callback with selected time. + * @param onTimeChange Callback on time change. + * @param onDismiss Callback on dismiss. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WheelTimePickerView( + modifier: Modifier = Modifier, + visible: Boolean = false, + displayMode: PickerDisplayMode = PickerDisplayMode.BOTTOM_SHEET, + startTime: PickerTime = PickerTime.now(), + wheelConfig: WheelPickerConfig = PickerDefaults.wheelPickerConfig(), + headerConfig: PickerHeaderConfig = PickerDefaults.headerConfig(title = "Select Time"), + timeConfig: TimePickerConfig = PickerDefaults.timePickerConfig(), + containerConfig: PickerContainerConfig = PickerDefaults.containerConfig(), + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + onDoneClick: (PickerTime) -> Unit = {}, + onTimeChange: (PickerTime) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + PickerContainer( + visible = visible, + displayMode = displayMode, + containerConfig = containerConfig, + dragHandle = dragHandle, + onDismiss = onDismiss + ) { + WheelTimePicker( + modifier = modifier, + startTime = startTime, + wheelConfig = wheelConfig, + headerConfig = headerConfig, + timeConfig = timeConfig, + onDoneClick = { time -> + onDoneClick(time) + onDismiss() + }, + onTimeChange = onTimeChange, + onCancel = onDismiss + ) + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/components/PickerContainer.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/components/PickerContainer.kt new file mode 100644 index 0000000..d5443d6 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/components/PickerContainer.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.components.picker.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.developerstring.jetco_kmp.components.picker.config.PickerContainerConfig +import com.developerstring.jetco_kmp.components.picker.config.PickerDefaults +import com.developerstring.jetco_kmp.components.picker.model.PickerDisplayMode + +/** + * Wraps picker [content] inside the appropriate container based on [displayMode]. + * + * - [PickerDisplayMode.DIALOG] → Material 3 AlertDialog + * - [PickerDisplayMode.BOTTOM_SHEET] → Material 3 ModalBottomSheet + * - [PickerDisplayMode.INLINE] → Directly renders content (no wrapper) + * + * @param visible Whether the picker container is currently shown. + * @param displayMode How the picker is presented. + * @param containerConfig Container visual configuration. + * @param dragHandle Custom drag handle for bottom sheet; null uses default. + * @param onDismiss Callback when the container is dismissed. + * @param content The picker content composable to embed. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PickerContainer( + visible: Boolean, + displayMode: PickerDisplayMode, + containerConfig: PickerContainerConfig = PickerDefaults.containerConfig(), + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + onDismiss: () -> Unit, + content: @Composable () -> Unit +) { + if (!visible) return + + when (displayMode) { + PickerDisplayMode.DIALOG -> { + AlertDialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false + ), + containerColor = containerConfig.containerColor, + shape = containerConfig.shape, + tonalElevation = containerConfig.tonalElevation, + confirmButton = {}, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + content() + } + } + ) + } + + PickerDisplayMode.BOTTOM_SHEET -> { + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = containerConfig.containerColor, + shape = containerConfig.shape, + tonalElevation = containerConfig.tonalElevation, + dragHandle = dragHandle + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) + ) { + content() + } + } + } + + PickerDisplayMode.INLINE -> { + Surface( + color = containerConfig.containerColor, + shape = containerConfig.shape, + tonalElevation = containerConfig.tonalElevation, + shadowElevation = containerConfig.shadowElevation + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + content() + } + } + } + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/components/PickerHeader.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/components/PickerHeader.kt new file mode 100644 index 0000000..34a00ff --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/components/PickerHeader.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.components.picker.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.picker.config.PickerHeaderConfig + +/** + * Displays the picker header row with title, optional cancel, and done button. + * + * @param config Header configuration. + * @param onDone Callback when done is clicked. + * @param onCancel Callback when cancel is clicked (only if cancelLabel is set). + */ +@Composable +internal fun PickerHeader( + config: PickerHeaderConfig, + onDone: () -> Unit, + onCancel: () -> Unit = {} +) { + if (!config.enabled) return + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Cancel button (optional) + if (config.cancelLabel != null) { + Text( + text = config.cancelLabel, + style = config.cancelLabelStyle, + color = config.cancelLabelColor, + modifier = Modifier + .clickable { onCancel() } + .padding(4.dp) + ) + } + + // Title + Text( + text = config.title, + style = config.titleStyle, + modifier = Modifier.weight(1f).padding(horizontal = 8.dp) + ) + + // Done button + Text( + text = config.doneLabel, + style = config.doneLabelStyle, + color = config.doneLabelColor, + modifier = Modifier + .clickable { onDone() } + .padding(4.dp) + ) + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/components/PickerWheel.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/components/PickerWheel.kt new file mode 100644 index 0000000..3172ff5 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/components/PickerWheel.kt @@ -0,0 +1,211 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.components.picker.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.developerstring.jetco_kmp.components.picker.config.WheelPickerConfig +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlin.math.abs + +/** + * A single-column wheel picker with snap-fling behaviour. + * + * Items are laid out in a [LazyColumn] that snaps to the nearest + * item after a fling. The centre row is highlighted with a + * configurable selector background, and edge rows gradually fade out. + * + * The visible height is calculated automatically from + * [WheelPickerConfig.selectorHeight] × [WheelPickerConfig.rowCount] + * to guarantee correct centering and snap alignment. + * + * @param items List of display strings for each option. + * @param initialIndex The index to scroll to initially (centred). + * @param config Visual / behavioural configuration. + * @param modifier Modifier applied to the outer container. + * @param onItemSelected Callback invoked with the new selected index. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun PickerWheel( + items: List, + initialIndex: Int, + config: WheelPickerConfig, + modifier: Modifier = Modifier, + onItemSelected: (index: Int) -> Unit +) { + if (items.isEmpty()) return + + val onItemSelectedState by rememberUpdatedState(onItemSelected) + + val halfPadding = config.rowCount / 2 + val itemHeight = config.selectorHeight + + // Enforce height = itemHeight * rowCount for correct centering + val totalHeight = itemHeight * config.rowCount + + // Content padding replaces fake empty items: each end gets + // halfPadding × itemHeight so the first/last real item can centre. + val verticalPadding = itemHeight * halfPadding + + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = initialIndex + ) + + val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState) + + // Derive the currently centred real-item index from layout info. + // derivedStateOf recomputes every frame during scroll but only + // notifies observers when the centred index actually changes, + // so items only recompose on selection change. + val currentIndex by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val viewportCenter = layoutInfo.viewportStartOffset + + (layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset) / 2 + layoutInfo.visibleItemsInfo + .minByOrNull { abs((it.offset + it.size / 2) - viewportCenter) } + ?.index?.coerceIn(0, items.lastIndex) + ?: initialIndex.coerceIn(0, items.lastIndex) + } + } + + // Side-effects: haptic feedback + callback on selection change. + // drop(1) skips the initial emit so the callback isn't fired + // redundantly on first composition. + LaunchedEffect(Unit) { + snapshotFlow { currentIndex } + .distinctUntilChanged() + .drop(1) + .collect { index -> + onItemSelectedState(index) + } + } + + Box( + modifier = modifier.height(totalHeight), + contentAlignment = Alignment.Center + ) { + LazyColumn( + state = listState, + flingBehavior = flingBehavior, + contentPadding = PaddingValues(vertical = verticalPadding), + modifier = Modifier + .fillMaxWidth() + .height(totalHeight) + .then( + if (config.fadeEdges) { + Modifier.drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + config.fadeEdgeColor, + Color.Transparent + ), + startY = 0f, + endY = size.height * 0.3f + ) + ) + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + config.fadeEdgeColor + ), + startY = size.height * 0.7f, + endY = size.height + ) + ) + } + } else Modifier + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + items( + count = items.size, + key = { it } + ) { index -> + val distance = abs(index - currentIndex) + val alpha = when (distance) { + 0 -> 1f + 1 -> 0.6f + 2 -> 0.3f + else -> 0.15f + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(itemHeight) + .graphicsLayer { this.alpha = alpha }, + contentAlignment = Alignment.Center + ) { + Text( + text = items[index], + style = if (index == currentIndex) { + config.selectedTextStyle + } else { + config.defaultTextStyle + }, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + } + + // Selector highlight overlay + Box( + modifier = Modifier + .fillMaxWidth() + .height(itemHeight) + .background( + color = config.selectorColor, + shape = config.selectorShape + ) + ) + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/config/PickerConfigurations.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/config/PickerConfigurations.kt new file mode 100644 index 0000000..a498b89 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/config/PickerConfigurations.kt @@ -0,0 +1,167 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.components.picker.config + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import com.developerstring.jetco_kmp.components.picker.model.DateOrder +import com.developerstring.jetco_kmp.components.picker.model.TimeFormat + +// ━━━━━━━━━━━━━━━━━━━━━ Wheel Picker Config ━━━━━━━━━━━━━━━━━━━━━━━━ + +/** + * Configuration for the wheel-scroll picker component. + * + * @property height Total height of the wheel picker area. + * @property rowCount Number of visible rows in the wheel (must be odd for centered selection). + * @property selectedTextStyle Text style for the currently selected item. + * @property defaultTextStyle Text style for non-selected items. + * @property selectorColor Background color of the selector highlight. + * @property selectorShape Shape of the selector highlight. + * @property selectorHeight Height of the selector highlight row. + * @property fadeEdges Whether to apply a fade/gradient effect at the top and bottom edges. + * @property fadeEdgeColor Color used for fading edges (usually matches background). + * @property hapticFeedback Whether to trigger haptic feedback on selection change. + */ +@Stable +data class WheelPickerConfig( + val height: Dp, + val rowCount: Int, + val selectedTextStyle: TextStyle, + val defaultTextStyle: TextStyle, + val selectorColor: Color, + val selectorShape: Shape, + val selectorHeight: Dp, + val fadeEdges: Boolean, + val fadeEdgeColor: Color, + val hapticFeedback: Boolean +) + +// ━━━━━━━━━━━━━━━━━━━━━ Picker Header Config ━━━━━━━━━━━━━━━━━━━━━━━ + +/** + * Configuration for the header section of a picker. + * + * @property enabled Whether to show the header (title + done button). + * @property title Title text displayed at the top. + * @property doneLabel Label text for the confirmation button. + * @property titleStyle Text style for the title. + * @property doneLabelStyle Text style for the done label. + * @property doneLabelColor Color for the done label text. + * @property cancelLabel Optional cancel label; if null, no cancel button is shown. + * @property cancelLabelStyle Text style for the cancel label. + * @property cancelLabelColor Color for the cancel label text. + */ +@Stable +data class PickerHeaderConfig( + val enabled: Boolean, + val title: String, + val doneLabel: String, + val titleStyle: TextStyle, + val doneLabelStyle: TextStyle, + val doneLabelColor: Color, + val cancelLabel: String?, + val cancelLabelStyle: TextStyle, + val cancelLabelColor: Color +) + +// ━━━━━━━━━━━━━━━━━━━━━ Date Picker Config ━━━━━━━━━━━━━━━━━━━━━━━━━ + +/** + * Configuration for date picker components. + * + * @property yearsRange Allowed year range for selection. + * @property dateOrder Order of day/month/year columns. + * @property showShortMonths Whether to use abbreviated month names (Jan, Feb…). + * @property showMonthAsNumber Whether to display months as numbers instead of names. + * @property customMonthNames Optional custom list of 12 month names. + */ +@Stable +data class DatePickerConfig( + val yearsRange: IntRange, + val dateOrder: DateOrder, + val showShortMonths: Boolean, + val showMonthAsNumber: Boolean, + val customMonthNames: List? +) + +// ━━━━━━━━━━━━━━━━━━━━━ Time Picker Config ━━━━━━━━━━━━━━━━━━━━━━━━━ + +/** + * Configuration for time picker components. + * + * @property timeFormat 12-hour or 24-hour display. + * @property showSeconds Whether to include a seconds column. + * @property minuteInterval Granularity of minute selection (1, 5, 10, 15, 30). + * @property secondInterval Granularity of second selection (1, 5, 10, 15, 30). + */ +@Stable +data class TimePickerConfig( + val timeFormat: TimeFormat, + val showSeconds: Boolean, + val minuteInterval: Int, + val secondInterval: Int +) + +// ━━━━━━━━━━━━━━━━━━━━━ Date Range Picker Config ━━━━━━━━━━━━━━━━━━━ + +/** + * Configuration specific to the date range picker. + * + * @property centerText Text displayed between the from-date and to-date boxes (e.g. "to"). + * @property boxColor Background color of date boxes. + * @property selectedBoxColor Background color of the currently selected date box. + * @property boxBorderColor Border color of date boxes. + * @property selectedBoxBorderColor Border color of the selected date box. + * @property selectedTextColor Text color inside the selected date box. + * @property boxShape Shape of the date boxes. + * @property boxTextStyle Text style for date text inside boxes. + * @property dateTextFormat Format pattern for displaying dates in boxes. + */ +@Stable +data class DateRangePickerConfig( + val centerText: String, + val boxColor: Color, + val selectedBoxColor: Color, + val boxBorderColor: Color, + val selectedBoxBorderColor: Color, + val selectedTextColor: Color, + val boxShape: Shape, + val boxTextStyle: TextStyle, + val dateTextFormat: String +) + +// ━━━━━━━━━━━━━━━━━━━━━ Container Config ━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/** + * Configuration for the outer container of dialog/bottom-sheet pickers. + * + * @property containerColor Background color of the picker container. + * @property shape Shape of the container. + * @property tonalElevation Tonal elevation for Material 3 surfaces. + * @property shadowElevation Shadow elevation for the container. + */ +@Stable +data class PickerContainerConfig( + val containerColor: Color, + val shape: Shape, + val tonalElevation: Dp, + val shadowElevation: Dp +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/config/PickerDefaults.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/config/PickerDefaults.kt new file mode 100644 index 0000000..1fe34cd --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/config/PickerDefaults.kt @@ -0,0 +1,270 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.components.picker.config + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.developerstring.jetco_kmp.components.picker.model.DateOrder +import com.developerstring.jetco_kmp.components.picker.model.TimeFormat + +/** + * Provides default configurations for all picker components. + * + * Every factory method mirrors the properties of the corresponding + * config `data class` and supplies sensible defaults. + * + * ### Example + * ```kotlin + * DatePicker( + * wheelConfig = PickerDefaults.wheelPickerConfig(rowCount = 5), + * headerConfig = PickerDefaults.headerConfig(title = "Select Date"), + * ) + * ``` + */ +object PickerDefaults { + + // ── Shared private defaults ────────────────────────────────────── + + private val defaultSelectedTextStyle = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF1A1A1A) + ) + + private val defaultTextStyle = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = Color(0xFF888888) + ) + + private val defaultTitleStyle = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF1A1A1A) + ) + + private val defaultDoneLabelStyle = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + + private val defaultCancelLabelStyle = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + + private val defaultAccentColor = Color(0xFF519DE9) + + // ── Wheel Picker ───────────────────────────────────────────────── + + /** + * Creates a [WheelPickerConfig] with the given parameters. + * + * @param height Total height of the wheel picker area. + * @param rowCount Number of visible rows (should be odd). + * @param selectedTextStyle Text style for the selected item. + * @param defaultTextStyle Text style for non-selected items. + * @param selectorColor Background color of the selector highlight. + * @param selectorShape Shape of the selector highlight. + * @param selectorHeight Height of the selector highlight row. + * @param fadeEdges Whether to fade edges. + * @param fadeEdgeColor Fade edge gradient color. + * @param hapticFeedback Whether to trigger haptic on change. + * @return A configured [WheelPickerConfig]. + */ + fun wheelPickerConfig( + height: Dp = 144.dp, + rowCount: Int = 3, + selectedTextStyle: TextStyle = defaultSelectedTextStyle, + defaultTextStyle: TextStyle = PickerDefaults.defaultTextStyle, + selectorColor: Color = Color(0x1A519DE9), + selectorShape: Shape = RoundedCornerShape(12.dp), + selectorHeight: Dp = 48.dp, + fadeEdges: Boolean = true, + fadeEdgeColor: Color = Color.White, + hapticFeedback: Boolean = true + ): WheelPickerConfig = WheelPickerConfig( + height = height, + rowCount = rowCount, + selectedTextStyle = selectedTextStyle, + defaultTextStyle = defaultTextStyle, + selectorColor = selectorColor, + selectorShape = selectorShape, + selectorHeight = selectorHeight, + fadeEdges = fadeEdges, + fadeEdgeColor = fadeEdgeColor, + hapticFeedback = hapticFeedback + ) + + // ── Header ─────────────────────────────────────────────────────── + + /** + * Creates a [PickerHeaderConfig] with the given parameters. + * + * @param enabled Whether to show the header. + * @param title Title text. + * @param doneLabel Done button label. + * @param titleStyle Title text style. + * @param doneLabelStyle Done button text style. + * @param doneLabelColor Done label color. + * @param cancelLabel Cancel button label; null hides the button. + * @param cancelLabelStyle Cancel button text style. + * @param cancelLabelColor Cancel label color. + * @return A configured [PickerHeaderConfig]. + */ + fun headerConfig( + enabled: Boolean = true, + title: String = "Select", + doneLabel: String = "Done", + titleStyle: TextStyle = defaultTitleStyle, + doneLabelStyle: TextStyle = defaultDoneLabelStyle, + doneLabelColor: Color = defaultAccentColor, + cancelLabel: String? = null, + cancelLabelStyle: TextStyle = defaultCancelLabelStyle, + cancelLabelColor: Color = Color(0xFF888888) + ): PickerHeaderConfig = PickerHeaderConfig( + enabled = enabled, + title = title, + doneLabel = doneLabel, + titleStyle = titleStyle, + doneLabelStyle = doneLabelStyle, + doneLabelColor = doneLabelColor, + cancelLabel = cancelLabel, + cancelLabelStyle = cancelLabelStyle, + cancelLabelColor = cancelLabelColor + ) + + // ── Date Picker ────────────────────────────────────────────────── + + /** + * Creates a [DatePickerConfig] with the given parameters. + * + * @param yearsRange Allowed year range. + * @param dateOrder Column ordering of day, month, year. + * @param showShortMonths Whether to abbreviate month names. + * @param showMonthAsNumber Whether to show months as numbers. + * @param customMonthNames Optional custom month names. + * @return A configured [DatePickerConfig]. + */ + fun datePickerConfig( + yearsRange: IntRange = IntRange(1922, 2122), + dateOrder: DateOrder = DateOrder.DAY_MONTH_YEAR, + showShortMonths: Boolean = false, + showMonthAsNumber: Boolean = false, + customMonthNames: List? = null + ): DatePickerConfig = DatePickerConfig( + yearsRange = yearsRange, + dateOrder = dateOrder, + showShortMonths = showShortMonths, + showMonthAsNumber = showMonthAsNumber, + customMonthNames = customMonthNames + ) + + // ── Time Picker ────────────────────────────────────────────────── + + /** + * Creates a [TimePickerConfig] with the given parameters. + * + * @param timeFormat 12-hour or 24-hour display. + * @param showSeconds Whether to include seconds. + * @param minuteInterval Minute step granularity. + * @param secondInterval Second step granularity. + * @return A configured [TimePickerConfig]. + */ + fun timePickerConfig( + timeFormat: TimeFormat = TimeFormat.HOUR_24, + showSeconds: Boolean = false, + minuteInterval: Int = 1, + secondInterval: Int = 1 + ): TimePickerConfig = TimePickerConfig( + timeFormat = timeFormat, + showSeconds = showSeconds, + minuteInterval = minuteInterval, + secondInterval = secondInterval + ) + + // ── Date Range Picker ──────────────────────────────────────────── + + /** + * Creates a [DateRangePickerConfig] with the given parameters. + * + * @param centerText Text between from/to boxes. + * @param boxColor Box background color. + * @param selectedBoxColor Selected box background color. + * @param boxBorderColor Box border color. + * @param selectedBoxBorderColor Selected box border color. + * @param selectedTextColor Selected text color. + * @param boxShape Box corner shape. + * @param boxTextStyle Text style for box content. + * @param dateTextFormat Date display format. + * @return A configured [DateRangePickerConfig]. + */ + fun dateRangePickerConfig( + centerText: String = "to", + boxColor: Color = Color.White, + selectedBoxColor: Color = Color(0xFFE9F2FE), + boxBorderColor: Color = Color(0xFFD0D0D0), + selectedBoxBorderColor: Color = Color(0xFF99BDFB), + selectedTextColor: Color = Color(0xFF5288F9), + boxShape: Shape = RoundedCornerShape(8.dp), + boxTextStyle: TextStyle = TextStyle( + color = Color(0xFF333333), + fontWeight = FontWeight.W600, + fontSize = 14.sp + ), + dateTextFormat: String = "yyyy-MM-dd" + ): DateRangePickerConfig = DateRangePickerConfig( + centerText = centerText, + boxColor = boxColor, + selectedBoxColor = selectedBoxColor, + boxBorderColor = boxBorderColor, + selectedBoxBorderColor = selectedBoxBorderColor, + selectedTextColor = selectedTextColor, + boxShape = boxShape, + boxTextStyle = boxTextStyle, + dateTextFormat = dateTextFormat + ) + + // ── Container ──────────────────────────────────────────────────── + + /** + * Creates a [PickerContainerConfig] with the given parameters. + * + * @param containerColor Background color. + * @param shape Container shape. + * @param tonalElevation M3 tonal elevation. + * @param shadowElevation Shadow elevation. + * @return A configured [PickerContainerConfig]. + */ + fun containerConfig( + containerColor: Color = Color.White, + shape: Shape = RoundedCornerShape(16.dp), + tonalElevation: Dp = 6.dp, + shadowElevation: Dp = 0.dp + ): PickerContainerConfig = PickerContainerConfig( + containerColor = containerColor, + shape = shape, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation + ) +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/model/PickerModels.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/model/PickerModels.kt new file mode 100644 index 0000000..0fcecb3 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/picker/model/PickerModels.kt @@ -0,0 +1,220 @@ +/* + * Copyright 2026 by Developer Chunk. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.developerstring.jetco_kmp.components.picker.model + +import androidx.compose.runtime.Stable +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +/** + * Determines the time display format. + * + * @property HOUR_12 12-hour clock with AM/PM indicator. + * @property HOUR_24 24-hour clock. + */ +@Stable +enum class TimeFormat { + HOUR_12, + HOUR_24 +} + +/** + * Determines how the picker is presented. + * + * @property DIALOG Picker is wrapped inside a Material 3 `AlertDialog`. + * @property BOTTOM_SHEET Picker is shown inside a Material 3 `ModalBottomSheet`. + * @property INLINE Picker content is rendered inline without a wrapping container. + */ +@Stable +enum class PickerDisplayMode { + DIALOG, + BOTTOM_SHEET, + INLINE +} + +/** + * Determines the date format pattern for display. + * + * @property DAY_MONTH_YEAR d / MMM / yyyy — e.g. 15 / Jan / 2026 + * @property MONTH_DAY_YEAR MMM / d / yyyy — e.g. Jan / 15 / 2026 + * @property YEAR_MONTH_DAY yyyy / MMM / d — e.g. 2026 / Jan / 15 + */ +@Stable +enum class DateOrder { + DAY_MONTH_YEAR, + MONTH_DAY_YEAR, + YEAR_MONTH_DAY +} + +/** + * Represents a selected date from the picker. + * + * @property year Selected year (e.g. 2026). + * @property month Selected month (1–12). + * @property dayOfMonth Selected day of the month (1–31). + */ +@Stable +data class PickerDate( + val year: Int, + val month: Int, + val dayOfMonth: Int +) { + /** + * Formats the date as `yyyy-MM-dd`. + */ + fun toFormattedString(): String { + return "${year}-${month.toString().padStart(2, '0')}-${dayOfMonth.toString().padStart(2, '0')}" + } + + /** + * Formats the date using the given [pattern]. + * + * Supported tokens: `yyyy`, `MM`, `dd`, `MMM`, `MMMM`, `d`, `M`. + */ + fun format(pattern: String): String { + val monthNames = listOf( + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ) + val shortMonthNames = listOf( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + ) + // Use NUL-delimited placeholders to avoid conflicts between + // token replacement and substituted text (e.g. "March" containing 'M'). + val phYear = "\u0000Y\u0000" + val phFullMonth = "\u0000FM\u0000" + val phShortMonth = "\u0000SM\u0000" + val phMonth2 = "\u0000M2\u0000" + val phDay2 = "\u0000D2\u0000" + val phDay = "\u0000D\u0000" + val phMonth = "\u0000M\u0000" + + return pattern + // First pass: replace tokens with placeholders (longest first) + .replace("yyyy", phYear) + .replace("MMMM", phFullMonth) + .replace("MMM", phShortMonth) + .replace("MM", phMonth2) + .replace("dd", phDay2) + .replace("d", phDay) + .replace("M", phMonth) + // Second pass: replace placeholders with actual values + .replace(phYear, year.toString()) + .replace(phFullMonth, monthNames.getOrElse(month - 1) { "" }) + .replace(phShortMonth, shortMonthNames.getOrElse(month - 1) { "" }) + .replace(phMonth2, month.toString().padStart(2, '0')) + .replace(phDay2, dayOfMonth.toString().padStart(2, '0')) + .replace(phDay, dayOfMonth.toString()) + .replace(phMonth, month.toString()) + } + + companion object { + /** + * Returns the current date using kotlinx-datetime. + */ + fun now(): PickerDate { + val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + return PickerDate( + year = today.year, + month = today.monthNumber, + dayOfMonth = today.dayOfMonth + ) + } + } +} + +/** + * Represents a selected time from the picker. + * + * @property hour Hour value (0–23). + * @property minute Minute value (0–59). + * @property second Second value (0–59). + */ +@Stable +data class PickerTime( + val hour: Int, + val minute: Int, + val second: Int = 0 +) { + /** + * Formats the time as `HH:mm` (24-hour) or `hh:mm a` (12-hour). + */ + fun toFormattedString(format: TimeFormat = TimeFormat.HOUR_24): String { + return when (format) { + TimeFormat.HOUR_24 -> { + "${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}" + } + TimeFormat.HOUR_12 -> { + val amPm = if (hour < 12) "AM" else "PM" + val h = if (hour == 0) 12 else if (hour > 12) hour - 12 else hour + "${h.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')} $amPm" + } + } + } + + companion object { + /** + * Returns the current time using kotlinx-datetime. + */ + fun now(): PickerTime { + val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + return PickerTime( + hour = now.hour, + minute = now.minute, + second = now.second + ) + } + } +} + +/** + * Represents a selected date and time from the picker. + * + * @property date The date component. + * @property time The time component. + */ +@Stable +data class PickerDateTime( + val date: PickerDate, + val time: PickerTime +) { + companion object { + /** + * Returns the current date and time. + */ + fun now(): PickerDateTime { + return PickerDateTime( + date = PickerDate.now(), + time = PickerTime.now() + ) + } + } +} + +/** + * Represents a date range selection with a start and end date. + * + * @property startDate The starting date of the range. + * @property endDate The ending date of the range. + */ +@Stable +data class PickerDateRange( + val startDate: PickerDate, + val endDate: PickerDate +) diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBar.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBar.kt new file mode 100644 index 0000000..20264fe --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBar.kt @@ -0,0 +1,321 @@ +package com.developerstring.jetco_kmp.components.search.animated_searchbar + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** + * A modern animated search bar component with smooth expansion, + * bounce effects, loading indicators, and full user control through a configuration + * system and controller API. + * + * This composable provides a minimal collapsed circular search button that expands + * into a full search bar with text input, clear action, and optional loading state. + * The expansion uses customizable spring animations, scale pop effects, and animated + * width transitions. Developers can trigger collapse externally using the + * [AnimatedSearchBarController]. + * + * ## Features: + * - Smooth expand/collapse animations using spring physics + * - Icon bounce & rotation animation on expansion + * - Configurable width, height, colors, radii, padding, and corner styling + * - Optional loading indicator that replaces the search icon when searching + * - Clear button with optional collapse behavior + * - IME "Search" action callback support + * - External collapse control through [AnimatedSearchBarController] + * - Fully customizable through [AnimatedSearchBarConfig] and + * [AnimatedSearchBarAnimationConfig] + * + * ## Example Usage: + * ```kotlin + * val query by remember { mutableStateOf("") } + * val controller = rememberAnimatedSearchBarController() + * + * AnimatedSearchBar( + * value = query, + * onValueChange = { query = it }, + * controller = controller, + * onSearch = { text -> + * // trigger real search + * }, + * config = AnimatedSearchBarConfig( + * collapsedWidth = 56.dp, + * expandedWidth = 300.dp, + * height = 48.dp, + * barBackgroundColor = Color.White, + * barCornerRadius = 32.dp + * ), + * animationConfig = AnimatedSearchBarAnimationConfig( + * widthSpringStiffness = Spring.StiffnessLow, + * widthSpringDamping = Spring.DampingRatioMediumBouncy + * ) + * ) + * ``` + * ## Collapsing search bar programmatically + * You can collapse the bar manually using the controller: + * + * ```kotlin + * controller.collapse() + * ``` + * This is useful when the search results list captures a click event. + * + * @param value Current text inside the search input. + * @param onValueChange Callback invoked when the text input changes. + * @param modifier Modifier applied to the entire search bar layout. + * @param config Appearance configuration. See [AnimatedSearchBarConfig]. + * @param animationConfig Animation configuration. See [AnimatedSearchBarAnimationConfig]. + * @param isLoading Displays a circular loading indicator instead of the search icon when true. + * @param onSearch Callback triggered when the user presses IME Search. + * @param controller Controls external behavior such as collapsing the search bar. + * @param onExpand Callback invoked when the bar expands. + * @param onCollapse Callback invoked when the bar collapses. + * + * @see AnimatedSearchBarConfig for appearance configuration options + * @see AnimatedSearchBarAnimationConfig for animation configuration options + * @see [AnimatedSearchBarController] for external collapse control + * + */ + +@Composable +fun AnimatedSearchBar( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + config: AnimatedSearchBarConfig = AnimatedSearchBarConfig(), + animationConfig: AnimatedSearchBarAnimationConfig = AnimatedSearchBarAnimationConfig(), + textConfig: AnimatedSearchBarTextConfig = AnimatedSearchBarTextConfig(), + isLoading: Boolean = false, + onSearch: (String) -> Unit = {}, + onExpand: () -> Unit = {}, + onCollapse: () -> Unit = {}, + controller: AnimatedSearchBarController = rememberAnimatedSearchBarController() +) { + var isExpanded by remember { mutableStateOf(false) } + + val iconScale = remember { Animatable(1f) } + + val scope = rememberCoroutineScope() + + fun triggerSearchIconBounceAnimation() { + scope.launch { + iconScale.animateTo( + 1.5f, animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + iconScale.animateTo( + 1f, animationSpec = spring( + dampingRatio = Spring.DampingRatioHighBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + } + } + + + val iconRotation by animateFloatAsState( + targetValue = if (isExpanded) 360f else 0f, animationSpec = tween( + durationMillis = 500, easing = LinearOutSlowInEasing + ) + ) + + val searchBarWidth by animateDpAsState( + targetValue = if (isExpanded) config.expandedWidth else config.collapsedWidth, + animationSpec = spring( + dampingRatio = animationConfig.widthSpringDamping, + stiffness = animationConfig.widthSpringStiffness + ) + ) + + val searchBarAlpha by animateFloatAsState( + targetValue = if (isExpanded) 1f else 0f, animationSpec = tween(durationMillis = 500) + ) + + val searchBarScale by animateFloatAsState( + targetValue = if (isExpanded) 1f else 0.8f, animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium + ) + ) + + val focusManager = LocalFocusManager.current + + LaunchedEffect(Unit) { + controller.collapseRequest = { + isExpanded = false + onCollapse() + triggerSearchIconBounceAnimation() + focusManager.clearFocus() + } + } + + Box( + modifier = modifier.width(searchBarWidth).height(config.height) + ) { + Box( + modifier = Modifier.fillMaxWidth().fillMaxHeight().scale(searchBarScale) + .alpha(searchBarAlpha).background( + color = config.searchBarBackgroundColor, + shape = RoundedCornerShape(config.searchBarCornerRadius) + ).border( + width = config.searchBarBorderWidth, + color = config.searchBarBorderColor, + shape = RoundedCornerShape(config.searchBarCornerRadius) + ) + ) + + + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier.size(config.height).padding(6.dp).scale(iconScale.value) + .rotate(iconRotation).background( + color = config.iconBackgroundColor, shape = CircleShape + ).clip(RoundedCornerShape(64.dp)).clickable { + if (!isExpanded) { + isExpanded = true + onExpand() + } + triggerSearchIconBounceAnimation() + }, contentAlignment = Alignment.Center + ) { + + val iconSize = config.height / 2 + + if (isExpanded && isLoading) { + CircularProgressIndicator( + strokeWidth = 2.dp, + color = config.iconTint, + modifier = Modifier.size(iconSize) + ) + } else { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = config.iconTint, + modifier = Modifier.size(iconSize) + ) + } + } + AnimatedVisibility( + visible = isExpanded, enter = fadeIn(tween(200)), exit = fadeOut(tween(150)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f).padding(horizontal = 16.dp) + ) { + BasicTextField( + value = value, + onValueChange = { onValueChange(it) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + onSearch(value) + focusManager.clearFocus() + }), + textStyle = textConfig.textStyle.copy( + color = textConfig.inputTextColor, + fontFamily = textConfig.fontFamily ?: textConfig.textStyle.fontFamily, + fontWeight = textConfig.fontWeight ?: textConfig.textStyle.fontWeight, + letterSpacing = textConfig.letterSpacing, + ), + cursorBrush = SolidColor(textConfig.cursorColor), + decorationBox = { innerTextField -> + if (value.isEmpty()) { + Box( + modifier = Modifier, contentAlignment = Alignment.CenterStart + ) { + Text( + config.placeholderTextString, + style = textConfig.placeholderTextStyle.copy( + color = textConfig.placeholderTextColor, + fontFamily = textConfig.placeholderFontFamily + ?: textConfig.placeholderTextStyle.fontFamily, + fontWeight = textConfig.placeholderFontWeight + ?: textConfig.placeholderTextStyle.fontWeight, + letterSpacing = textConfig.letterSpacing + ), + modifier = Modifier.padding(start = 2.dp) + ) + } + } + innerTextField() + }) + + IconButton( + onClick = { + if (value.isNotEmpty()) { + onValueChange("") + focusManager.clearFocus() + } else { + onValueChange("") + isExpanded = false + onCollapse() + triggerSearchIconBounceAnimation() + } + }, modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear Search", + tint = config.clearIconTint, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarAnimationConfig.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarAnimationConfig.kt new file mode 100644 index 0000000..ca88bb2 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarAnimationConfig.kt @@ -0,0 +1,46 @@ +package com.developerstring.jetco_kmp.components.search.animated_searchbar + +import androidx.compose.animation.core.Spring + +/** + * Animation configuration class for [AnimatedSearchBar]. + * + * Controls spring physics, animation timings, and overshoot values for both the + * width expansion and the scale "pop" effect. + * + * @param rotationDuration Duration (in milliseconds) of the search-icon rotation + * that occurs during expansion. + * Default is **500ms**. + * + * @param bounceStiffness Spring stiffness applied to the icon bounce animation. + * Lower stiffness = softer bounce, higher stiffness = snappier. + * Default is **Spring.StiffnessMediumLow**. + * + * @param bounceDamping Spring damping ratio for bounce animation. + * Higher damping reduces oscillation. + * Default is **Spring.DampingRatioMediumBouncy**. + * + * @param widthSpringStiffness Spring stiffness for width expansion/collapse animation. + * Controls how fast the bar grows/shrinks. + * Default is **Spring.StiffnessLow**. + * + * @param widthSpringDamping Damping ratio applied during width animation. + * Controls how much overshoot or bounce occurs. + * Default is **Spring.DampingRatioLowBouncy**. + * + * @param fadeDuration Duration (in milliseconds) for fade-in / fade-out transitions + * used when showing or hiding content inside the expanded bar. + * Default is **200ms**. + * + * @see AnimatedSearchBarConfig for appearance configuration options + */ +data class AnimatedSearchBarAnimationConfig( + val rotationDuration: Int = 500, + val bounceStiffness: Float = Spring.StiffnessMediumLow, + val bounceDamping: Float = Spring.DampingRatioMediumBouncy, + + val widthSpringStiffness: Float = Spring.StiffnessLow, + val widthSpringDamping: Float = Spring.DampingRatioLowBouncy, + + val fadeDuration: Int = 200, +) \ No newline at end of file diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarConfig.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarConfig.kt new file mode 100644 index 0000000..f911b49 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarConfig.kt @@ -0,0 +1,71 @@ +package com.developerstring.jetco_kmp.components.search.animated_searchbar + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Configuration class for the visual appearance and behaviour of [AnimatedSearchBar]. + * + * Use this to tune sizes, colors, radii, spacing and other visual defaults. + * + * @param collapsedWidth Width of the bar when collapsed into a circular icon. + * Default is 64.dp. + * + * @param expandedWidth Target width when the search bar is fully expanded. + * Default is 320.dp. + * Note: The parent layout may still constrain this width. + * + * @param height Total height of the search bar, including icon and text field. + * Default is 48.dp. + * + * @param searchBarBackgroundColor Background color of the expanded search bar container. + * + * @param searchBarBorderWidth Width of the border around the expanded bar. + * Default is 2.dp. + * + * @param searchBarBorderColor Border color for the expanded bar. + * Only visible when `barBorderWidth` > 0.dp. + * + * @param searchBarCornerRadius Corner radius of the expanded search bar container. + * Default is 35.dp for a rounded-pill shape. + * + * @param iconBackgroundColor Background color of the circular icon container in collapsed mode. + * + * @param iconTint Tint color for the search icon inside the collapsed button. + * + * @param clearIconTint Tint color for the clear (✕) icon shown when expanded. + * + * @param placeholderTextString Text displayed when the input field is empty. Default is "Search". + * + * Example: + * ```kotlin + * AnimatedSearchBarConfig( + * collapsedWidth = 56.dp, + * expandedWidth = 320.dp, + * height = 48.dp, + * barBackgroundColor = Color.White, + * barCornerRadius = 28.dp + * ) + * ``` + * + * @see AnimatedSearchBar for usage + * @see AnimatedSearchBarTextConfig for text configuration options + * @see AnimatedSearchBarAnimationConfig for animation configuration options + */ + +data class AnimatedSearchBarConfig( + val height: Dp = 48.dp, + val expandedWidth: Dp = 320.dp, + val collapsedWidth: Dp = 64.dp, + + val searchBarBackgroundColor: Color = Color.White, + val searchBarCornerRadius: Dp = 35.dp, + val searchBarBorderColor: Color = Color(0xFFE0E0E0), + val searchBarBorderWidth: Dp = 2.dp, + + val iconBackgroundColor: Color = Color(0xFF558B2F), + val iconTint: Color = Color.White, + val clearIconTint: Color = Color.Black, + val placeholderTextString: String = "Search" +) \ No newline at end of file diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarController.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarController.kt new file mode 100644 index 0000000..3644bbc --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarController.kt @@ -0,0 +1,36 @@ +package com.developerstring.jetco_kmp.components.search.animated_searchbar + +import androidx.compose.runtime.Stable + +/** + * Controller used to interact with [AnimatedSearchBar] from outside the component. + * + * A controller enables external UI elements (such as search result lists, navigation + * actions, or parent screens) to programmatically collapse the search bar without + * requiring direct state lifting. + * + * ## Usage Example + * ```kotlin + * val controller = rememberAnimatedSearchBarController() + * + * AnimatedSearchBar( + * value = query, + * onValueChange = { query = it }, + * controller = controller + * ) + * + * // Collapse from outside (e.g., when a result is clicked) + * controller.collapse() + * ``` + * Internally, [AnimatedSearchBar] registers a collapse request callback so the controller + * always triggers the same close animation and logic as the built-in clear button. + **/ + +@Stable +class AnimatedSearchBarController { + internal var collapseRequest: (() -> Unit)? = null + + fun collapse() { + collapseRequest?.invoke() + } +} \ No newline at end of file diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarTextConfig.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarTextConfig.kt new file mode 100644 index 0000000..92bd97d --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/AnimatedSearchBarTextConfig.kt @@ -0,0 +1,55 @@ +package com.developerstring.jetco_kmp.components.search.animated_searchbar + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +/** + * Configuration class for text input customization in [AnimatedSearchBar]. + * + * @param textStyle Base text style for the input text. + * @param fontFamily Custom font family for input text. + * @param fontWeight Custom font weight for input text. + * @param letterSpacing Letter spacing for input text. + * @param inputTextColor Text color for the search bar input text. + * @param placeholderTextStyle Text style for the placeholder (size, line height, etc.). + * @param placeholderFontFamily Custom font family for placeholder text. + * @param placeholderFontWeight Custom font weight for placeholder text. + * @param placeholderTextColor Color for the placeholder text. (Text string is in [AnimatedSearchBarConfig]) + * @param cursorColor Color of the text cursor. + * + * Example: + * ```kotlin + * AnimatedSearchBarTextConfig( + * fontFamily = FontFamily.SansSerif, + * fontWeight = FontWeight.Medium, + * letterSpacing = 0.5.sp, + * inputTextColor = Color.Black, + * placeholderTextColor = Color.Black, + * cursorColor = Color.Blue + * ) + * ``` + * + * @see AnimatedSearchBarConfig for appearance configuration options + * @see AnimatedSearchBarAnimationConfig for animation configuration options + */ + +data class AnimatedSearchBarTextConfig( + val textStyle: TextStyle = TextStyle( + fontSize = 16.sp, lineHeight = 18.sp + ), + val fontFamily: FontFamily? = null, + val fontWeight: FontWeight? = null, + val letterSpacing: TextUnit = TextUnit.Unspecified, + val inputTextColor: Color = Color.Black, + val placeholderTextStyle: TextStyle = TextStyle( + fontSize = 16.sp, lineHeight = 18.sp + ), + val placeholderFontFamily: FontFamily? = null, + val placeholderFontWeight: FontWeight? = null, + val placeholderTextColor: Color = Color.Black, + val cursorColor: Color = Color.DarkGray +) \ No newline at end of file diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/RememberAnimatedSearchBarController.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/RememberAnimatedSearchBarController.kt new file mode 100644 index 0000000..90cb255 --- /dev/null +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/search/animated_searchbar/RememberAnimatedSearchBarController.kt @@ -0,0 +1,25 @@ +package com.developerstring.jetco_kmp.components.search.animated_searchbar + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +/** + * Creates and remembers an instance of [AnimatedSearchBarController] for use with + * [AnimatedSearchBar]. + * + * The controller allows the parent UI to trigger collapse behavior externally, + * enabling scenarios where: + * - the user selects a search result, + * - a navigation event occurs, + * - or the screen must reset the search bar programmatically. + * + * ## Usage Example + * ```kotlin + * val controller = rememberAnimatedSearchBarController() + * ``` + **/ + +@Composable +fun rememberAnimatedSearchBarController(): AnimatedSearchBarController { + return remember { AnimatedSearchBarController() } +} diff --git a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/stepper/CompactHorizontalStepper.kt b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/stepper/CompactHorizontalStepper.kt index caeab9b..51e57e4 100644 --- a/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/stepper/CompactHorizontalStepper.kt +++ b/JetCoKMP/jetco/src/commonMain/kotlin/com/developerstring/jetco_kmp/components/stepper/CompactHorizontalStepper.kt @@ -24,11 +24,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.unit.dp import com.developerstring.jetco_kmp.components.stepper.model.StepperActionIcons import com.developerstring.jetco_kmp.components.stepper.model.StepperConfig import com.developerstring.jetco_kmp.components.stepper.model.StepperNode +import com.developerstring.jetco_kmp.components.stepper.model.StepperStatus /** * A compact horizontal stepper component for Jetpack Compose. @@ -92,7 +94,6 @@ fun CompactHorizontalStepper( animationTriggered = true } - Row( modifier = modifier .fillMaxWidth() @@ -100,10 +101,12 @@ fun CompactHorizontalStepper( verticalAlignment = Alignment.CenterVertically ) { steps.forEachIndexed { index, step -> - val isActive = index == currentStep - 1 - val isCompleted = index < currentStep-1 + val isActive = index == currentStep + val isCompleted = index < currentStep + val isError = step.status == StepperStatus.ERROR val nodeColor = when { + isError -> style.node.errorColor isCompleted -> style.node.completedColor isActive -> style.node.activeColor else -> style.node.inactiveColor @@ -117,34 +120,45 @@ fun CompactHorizontalStepper( colors = CardDefaults.cardColors(containerColor = nodeColor), onClick = { onStepClick?.invoke(index) } ) { + val prefix = "${index}_step_" Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - if (isCompleted || index == currentStep-1) { - Icon( - imageVector = stepperActionIcons.completed, - contentDescription = "Completed", - tint = style.node.actionIconColor, - modifier = Modifier.size(12.dp) + val (iconVector, iconTint, iconDescription) = when { + isError -> Triple( + stepperActionIcons.error, + style.node.actionIconColor, + prefix + "error" + ) + isCompleted || index == currentStep - 1 -> Triple( + stepperActionIcons.completed, + style.node.actionIconColor, + prefix + "completed" + ) + isActive && step.icon != null -> Triple( + step.icon, + style.node.actionIconColor, + prefix + "active" + ) + step.icon != null -> Triple( + step.icon, + style.node.idleIconColor, + prefix + "idle" + ) + else -> Triple( + stepperActionIcons.active, + style.node.idleIconColor, + prefix + "idle" ) - } else { - if (step.icon!= null) { - Icon( - imageVector = step.icon, - contentDescription = "Completed", - tint = style.node.idleIconColor, - modifier = Modifier.size(12.dp) - ) - } else { - Icon( - imageVector = stepperActionIcons.active, - contentDescription = "Completed", - tint = style.node.idleIconColor, - modifier = Modifier.size(12.dp) - ) - } } + + Icon( + imageVector = iconVector, + contentDescription = iconDescription, + tint = iconTint, + modifier = Modifier.size(12.dp) + ) } } @@ -170,4 +184,4 @@ fun CompactHorizontalStepper( } } } -} +} \ No newline at end of file diff --git a/JetCoKMP/jetco/src/commonTest/kotlin/FibiTest.kt b/JetCoKMP/jetco/src/commonTest/kotlin/FibiTest.kt new file mode 100644 index 0000000..0fe7c14 --- /dev/null +++ b/JetCoKMP/jetco/src/commonTest/kotlin/FibiTest.kt @@ -0,0 +1 @@ +package com.developerstring.jetco_kmp diff --git a/JetCoKMP/jetco/src/iosMain/kotlin/fibiprops.ios.kt b/JetCoKMP/jetco/src/iosMain/kotlin/fibiprops.ios.kt new file mode 100644 index 0000000..0fe7c14 --- /dev/null +++ b/JetCoKMP/jetco/src/iosMain/kotlin/fibiprops.ios.kt @@ -0,0 +1 @@ +package com.developerstring.jetco_kmp diff --git a/JetCoKMP/kotlin-js-store/yarn.lock b/JetCoKMP/kotlin-js-store/yarn.lock new file mode 100644 index 0000000..97f5992 --- /dev/null +++ b/JetCoKMP/kotlin-js-store/yarn.lock @@ -0,0 +1,3050 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@colors/colors@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" + integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== + +"@discoveryjs/json-ext@^0.6.1": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz#f13c7c205915eb91ae54c557f5e92bddd8be0e83" + integrity sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/source-map@^0.3.3": + version "0.3.11" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.11.tgz#b21835cbd36db656b857c2ad02ebd413cc13a9ba" + integrity sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@js-joda/core@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" + integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== + +"@jsonjoy.com/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-1.1.2.tgz#cf8ea9dcb849b81c95f14fc0aaa151c6b54d2578" + integrity sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA== + +"@jsonjoy.com/buffers@^1.0.0", "@jsonjoy.com/buffers@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/buffers/-/buffers-1.2.0.tgz#57b9bbc509055de80f22cf6b696ac7efd7554046" + integrity sha512-6RX+W5a+ZUY/c/7J5s5jK9UinLfJo5oWKh84fb4X0yK2q4WXEWUWZWuEMjvCb1YNUQhEAhUfr5scEGOH7jC4YQ== + +"@jsonjoy.com/codegen@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz#5c23f796c47675f166d23b948cdb889184b93207" + integrity sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g== + +"@jsonjoy.com/json-pack@^1.11.0": + version "1.20.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pack/-/json-pack-1.20.0.tgz#c59cbac0f3fcab0fa9fd5a36cd2b15d020b0bc0a" + integrity sha512-adcXFVorSQULtT4XDL0giRLr2EVGIcyWm6eQKZWTrRA4EEydGOY8QVQtL0PaITQpUyu+lOd/QOicw6vdy1v8QQ== + dependencies: + "@jsonjoy.com/base64" "^1.1.2" + "@jsonjoy.com/buffers" "^1.2.0" + "@jsonjoy.com/codegen" "^1.0.0" + "@jsonjoy.com/json-pointer" "^1.0.2" + "@jsonjoy.com/util" "^1.9.0" + hyperdyperid "^1.2.0" + thingies "^2.5.0" + +"@jsonjoy.com/json-pointer@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz#049cb530ac24e84cba08590c5e36b431c4843408" + integrity sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg== + dependencies: + "@jsonjoy.com/codegen" "^1.0.0" + "@jsonjoy.com/util" "^1.9.0" + +"@jsonjoy.com/util@^1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-1.9.0.tgz#7ee95586aed0a766b746cd8d8363e336c3c47c46" + integrity sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ== + dependencies: + "@jsonjoy.com/buffers" "^1.0.0" + "@jsonjoy.com/codegen" "^1.0.0" + +"@leichtgewicht/ip-codec@^2.0.1": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1" + integrity sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw== + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@socket.io/component-emitter@~3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" + integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== + +"@types/body-parser@*": + version "1.19.6" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.6.tgz#1859bebb8fd7dac9918a45d54c1971ab8b5af474" + integrity sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/bonjour@^3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@types/bonjour/-/bonjour-3.5.13.tgz#adf90ce1a105e81dd1f9c61fdc5afda1bfb92956" + integrity sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ== + dependencies: + "@types/node" "*" + +"@types/connect-history-api-fallback@^1.5.4": + version "1.5.4" + resolved "https://registry.yarnpkg.com/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz#7de71645a103056b48ac3ce07b3520b819c1d5b3" + integrity sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw== + dependencies: + "@types/express-serve-static-core" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + +"@types/cors@^2.8.12": + version "2.8.19" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.19.tgz#d93ea2673fd8c9f697367f5eeefc2bbfa94f0342" + integrity sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg== + dependencies: + "@types/node" "*" + +"@types/eslint-scope@^3.7.7": + version "3.7.7" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" + integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg== + dependencies: + "@types/eslint" "*" + "@types/estree" "*" + +"@types/eslint@*": + version "9.6.1" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-9.6.1.tgz#d5795ad732ce81715f27f75da913004a56751584" + integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/estree@*", "@types/estree@^1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz#74f47555b3d804b54cb7030e6f9aa0c7485cfc5b" + integrity sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express-serve-static-core@^4.17.21", "@types/express-serve-static-core@^4.17.33": + version "4.19.7" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz#f1d306dcc03b1aafbfb6b4fe684cce8a31cffc10" + integrity sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@*": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.3.tgz#6c4bc6acddc2e2a587142e1d8be0bce20757e956" + integrity sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^5.0.0" + "@types/serve-static" "*" + +"@types/express@^4.17.21": + version "4.17.23" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.23.tgz#35af3193c640bfd4d7fe77191cd0ed411a433bef" + integrity sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/http-errors@*": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472" + integrity sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg== + +"@types/http-proxy@^1.17.8": + version "1.17.16" + resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.16.tgz#dee360707b35b3cc85afcde89ffeebff7d7f9240" + integrity sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w== + dependencies: + "@types/node" "*" + +"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.9": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/mime@^1": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" + integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== + +"@types/node-forge@^1.3.0": + version "1.3.14" + resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.14.tgz#006c2616ccd65550560c2757d8472eb6d3ecea0b" + integrity sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw== + dependencies: + "@types/node" "*" + +"@types/node@*", "@types/node@>=10.0.0": + version "24.7.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.7.2.tgz#5adf66b6e2ac5cab1d10a2ad3682e359cb652f4a" + integrity sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA== + dependencies: + undici-types "~7.14.0" + +"@types/qs@*": + version "6.14.0" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.14.0.tgz#d8b60cecf62f2db0fb68e5e006077b9178b85de5" + integrity sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ== + +"@types/range-parser@*": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" + integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== + +"@types/retry@0.12.2": + version "0.12.2" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.2.tgz#ed279a64fa438bb69f2480eda44937912bb7480a" + integrity sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow== + +"@types/send@*": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@types/send/-/send-1.2.0.tgz#ae9dfa0e3ab0306d3c566182324a54c4be2fb45a" + integrity sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ== + dependencies: + "@types/node" "*" + +"@types/send@<1": + version "0.17.5" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.5.tgz#d991d4f2b16f2b1ef497131f00a9114290791e74" + integrity sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@types/serve-index@^1.9.4": + version "1.9.4" + resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.4.tgz#e6ae13d5053cb06ed36392110b4f9a49ac4ec898" + integrity sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug== + dependencies: + "@types/express" "*" + +"@types/serve-static@*", "@types/serve-static@^1.15.5": + version "1.15.9" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.9.tgz#f9b08ab7dd8bbb076f06f5f983b683654fe0a025" + integrity sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA== + dependencies: + "@types/http-errors" "*" + "@types/node" "*" + "@types/send" "<1" + +"@types/sockjs@^0.3.36": + version "0.3.36" + resolved "https://registry.yarnpkg.com/@types/sockjs/-/sockjs-0.3.36.tgz#ce322cf07bcc119d4cbf7f88954f3a3bd0f67535" + integrity sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q== + dependencies: + "@types/node" "*" + +"@types/ws@^8.5.10": + version "8.18.1" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" + integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== + dependencies: + "@types/node" "*" + +"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.14.1.tgz#a9f6a07f2b03c95c8d38c4536a1fdfb521ff55b6" + integrity sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ== + dependencies: + "@webassemblyjs/helper-numbers" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + +"@webassemblyjs/floating-point-hex-parser@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz#fcca1eeddb1cc4e7b6eed4fc7956d6813b21b9fb" + integrity sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA== + +"@webassemblyjs/helper-api-error@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz#e0a16152248bc38daee76dd7e21f15c5ef3ab1e7" + integrity sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ== + +"@webassemblyjs/helper-buffer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz#822a9bc603166531f7d5df84e67b5bf99b72b96b" + integrity sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA== + +"@webassemblyjs/helper-numbers@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz#dbd932548e7119f4b8a7877fd5a8d20e63490b2d" + integrity sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA== + dependencies: + "@webassemblyjs/floating-point-hex-parser" "1.13.2" + "@webassemblyjs/helper-api-error" "1.13.2" + "@xtuc/long" "4.2.2" + +"@webassemblyjs/helper-wasm-bytecode@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz#e556108758f448aae84c850e593ce18a0eb31e0b" + integrity sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA== + +"@webassemblyjs/helper-wasm-section@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz#9629dda9c4430eab54b591053d6dc6f3ba050348" + integrity sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/wasm-gen" "1.14.1" + +"@webassemblyjs/ieee754@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz#1c5eaace1d606ada2c7fd7045ea9356c59ee0dba" + integrity sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw== + dependencies: + "@xtuc/ieee754" "^1.2.0" + +"@webassemblyjs/leb128@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.13.2.tgz#57c5c3deb0105d02ce25fa3fd74f4ebc9fd0bbb0" + integrity sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw== + dependencies: + "@xtuc/long" "4.2.2" + +"@webassemblyjs/utf8@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.13.2.tgz#917a20e93f71ad5602966c2d685ae0c6c21f60f1" + integrity sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ== + +"@webassemblyjs/wasm-edit@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz#ac6689f502219b59198ddec42dcd496b1004d597" + integrity sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/helper-wasm-section" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-opt" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + "@webassemblyjs/wast-printer" "1.14.1" + +"@webassemblyjs/wasm-gen@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz#991e7f0c090cb0bb62bbac882076e3d219da9570" + integrity sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" + +"@webassemblyjs/wasm-opt@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz#e6f71ed7ccae46781c206017d3c14c50efa8106b" + integrity sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + +"@webassemblyjs/wasm-parser@1.14.1", "@webassemblyjs/wasm-parser@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz#b3e13f1893605ca78b52c68e54cf6a865f90b9fb" + integrity sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-api-error" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" + +"@webassemblyjs/wast-printer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz#3bb3e9638a8ae5fdaf9610e7a06b4d9f9aa6fe07" + integrity sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@xtuc/long" "4.2.2" + +"@webpack-cli/configtest@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-3.0.1.tgz#76ac285b9658fa642ce238c276264589aa2b6b57" + integrity sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA== + +"@webpack-cli/info@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-3.0.1.tgz#3cff37fabb7d4ecaab6a8a4757d3826cf5888c63" + integrity sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ== + +"@webpack-cli/serve@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-3.0.1.tgz#bd8b1f824d57e30faa19eb78e4c0951056f72f00" + integrity sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg== + +"@xtuc/ieee754@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" + integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== + +"@xtuc/long@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" + integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== + +accepts@~1.3.4, accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +acorn-import-phases@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7" + integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ== + +acorn@^8.15.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +ajv-formats@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" + integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== + dependencies: + ajv "^8.0.0" + +ajv-keywords@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz#69d4d385a4733cdbeab44964a1170a88f87f0e16" + integrity sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw== + dependencies: + fast-deep-equal "^3.1.3" + +ajv@^8.0.0, ajv@^8.9.0: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + +ansi-html-community@^0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/ansi-html-community/-/ansi-html-community-0.0.8.tgz#69fbc4d6ccbe383f9736934ae34c3f8290f1bf41" + integrity sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base64id@2.0.0, base64id@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" + integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== + +baseline-browser-mapping@^2.8.9: + version "2.8.16" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz#e17789673e7f4b7654f81ab2ef25e96ab6a895f9" + integrity sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw== + +batch@0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" + integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +body-parser@1.20.3, body-parser@^1.19.0: + version "1.20.3" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" + integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.13.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + +bonjour-service@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/bonjour-service/-/bonjour-service-1.3.0.tgz#80d867430b5a0da64e82a8047fc1e355bdb71722" + integrity sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA== + dependencies: + fast-deep-equal "^3.1.3" + multicast-dns "^7.2.5" + +brace-expansion@^1.1.7: + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.2, braces@^3.0.3, braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browser-stdout@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +browserslist@^4.24.0: + version "4.26.3" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.26.3.tgz#40fbfe2d1cd420281ce5b1caa8840049c79afb56" + integrity sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w== + dependencies: + baseline-browser-mapping "^2.8.9" + caniuse-lite "^1.0.30001746" + electron-to-chromium "^1.5.227" + node-releases "^2.0.21" + update-browserslist-db "^1.1.3" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +bundle-name@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bundle-name/-/bundle-name-4.1.0.tgz#f3b96b34160d6431a19d7688135af7cfb8797889" + integrity sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q== + dependencies: + run-applescript "^7.0.0" + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bound@^1.0.2, call-bound@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +camelcase@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +caniuse-lite@^1.0.30001746: + version "1.0.30001750" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz#c229f82930033abd1502c6f73035356cf528bfbc" + integrity sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ== + +chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chokidar@^3.5.1, chokidar@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chokidar@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + +chrome-trace-event@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b" + integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ== + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +clone-deep@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" + integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== + dependencies: + is-plain-object "^2.0.4" + kind-of "^6.0.2" + shallow-clone "^3.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +colorette@^2.0.10, colorette@^2.0.14: + version "2.0.20" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" + integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== + +commander@^12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== + +commander@^2.20.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +compressible@~2.0.18: + version "2.0.18" + resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" + integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== + dependencies: + mime-db ">= 1.43.0 < 2" + +compression@^1.7.4: + version "1.8.1" + resolved "https://registry.yarnpkg.com/compression/-/compression-1.8.1.tgz#4a45d909ac16509195a9a28bd91094889c180d79" + integrity sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w== + dependencies: + bytes "3.1.2" + compressible "~2.0.18" + debug "2.6.9" + negotiator "~0.6.4" + on-headers "~1.1.0" + safe-buffer "5.2.1" + vary "~1.1.2" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +connect-history-api-fallback@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz#647264845251a0daf25b97ce87834cace0f5f1c8" + integrity sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA== + +connect@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8" + integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ== + dependencies: + debug "2.6.9" + finalhandler "1.1.2" + parseurl "~1.3.3" + utils-merge "1.0.1" + +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9" + integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w== + +cookie@~0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cors@~2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +cross-spawn@^7.0.3, cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +custom-event@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" + integrity sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg== + +date-format@^4.0.14: + version "4.0.14" + resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.14.tgz#7a8e584434fb169a521c8b7aa481f355810d9400" + integrity sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg== + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^4.1.0, debug@^4.3.4, debug@^4.3.5: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + +default-browser-id@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/default-browser-id/-/default-browser-id-5.0.0.tgz#a1d98bf960c15082d8a3fa69e83150ccccc3af26" + integrity sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA== + +default-browser@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/default-browser/-/default-browser-5.2.1.tgz#7b7ba61204ff3e425b556869ae6d3e9d9f1712cf" + integrity sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg== + dependencies: + bundle-name "^4.1.0" + default-browser-id "^5.0.0" + +define-lazy-prop@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz#dbb19adfb746d7fc6d734a06b72f4a00d021255f" + integrity sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg== + +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +depd@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +detect-node@^2.0.4: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" + integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== + +di@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c" + integrity sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA== + +diff@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" + integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== + +dns-packet@^5.2.2: + version "5.6.1" + resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-5.6.1.tgz#ae888ad425a9d1478a0674256ab866de1012cf2f" + integrity sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw== + dependencies: + "@leichtgewicht/ip-codec" "^2.0.1" + +dom-serialize@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b" + integrity sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ== + dependencies: + custom-event "~1.0.0" + ent "~2.2.0" + extend "^3.0.0" + void-elements "^2.0.0" + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +electron-to-chromium@^1.5.227: + version "1.5.234" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.234.tgz#d895b6dba84269f4e83b3a1149dcc55e27848c30" + integrity sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + +engine.io-parser@~5.2.1: + version "5.2.3" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f" + integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== + +engine.io@~6.6.0: + version "6.6.4" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.6.4.tgz#0a89a3e6b6c1d4b0c2a2a637495e7c149ec8d8ee" + integrity sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g== + dependencies: + "@types/cors" "^2.8.12" + "@types/node" ">=10.0.0" + accepts "~1.3.4" + base64id "2.0.0" + cookie "~0.7.2" + cors "~2.8.5" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.17.1" + +enhanced-resolve@^5.17.2: + version "5.18.3" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz#9b5f4c5c076b8787c78fe540392ce76a88855b44" + integrity sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + +ent@~2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.2.tgz#22a5ed2fd7ce0cbcff1d1474cf4909a44bdb6e85" + integrity sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + punycode "^1.4.1" + safe-regex-test "^1.1.0" + +envinfo@^7.14.0: + version "7.18.0" + resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.18.0.tgz#38793d9dab9a5dec7b2a3146ed094cda8e754ed8" + integrity sha512-02QGCLRW+Jb8PC270ic02lat+N57iBaWsvHjcJViqp6UVupRB+Vsg7brYPTqEFXvsdTql3KnSczv5ModZFpl8Q== + +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-module-lexer@^1.2.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +escalade@^3.1.1, escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-scope@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== + dependencies: + esrecurse "^4.3.0" + estraverse "^4.1.1" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^4.1.1: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +eventemitter3@^4.0.0: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +events@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +express@^4.21.2: + version "4.21.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.21.2.tgz#cf250e48362174ead6cea4a566abef0162c1ec32" + integrity sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.3" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.7.1" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~2.0.0" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.3.1" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.3" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.12" + proxy-addr "~2.0.7" + qs "6.13.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.19.0" + serve-static "1.16.2" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-uri@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" + integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== + +fastest-levenshtein@^1.0.12: + version "1.0.16" + resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" + integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== + +faye-websocket@^0.11.3: + version "0.11.4" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" + integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== + dependencies: + websocket-driver ">=0.5.1" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +finalhandler@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" + integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "~2.3.0" + parseurl "~1.3.3" + statuses "~1.5.0" + unpipe "~1.0.0" + +finalhandler@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019" + integrity sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ== + dependencies: + debug "2.6.9" + encodeurl "~2.0.0" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + +find-up@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +flatted@^3.2.7: + version "3.3.3" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + +follow-redirects@^1.0.0: + version "1.15.11" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" + integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +format-util@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271" + integrity sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg== + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +fs-extra@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-to-regex.js@^1.0.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz#2b323728271d133830850e32311f40766c5f6413" + integrity sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ== + +glob-to-regexp@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" + integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== + +glob@^10.4.5: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^7.1.3, glob@^7.1.7: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +handle-thing@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" + integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +hpack.js@^2.1.6: + version "2.1.6" + resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" + integrity sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ== + dependencies: + inherits "^2.0.1" + obuf "^1.0.0" + readable-stream "^2.0.1" + wbuf "^1.1.0" + +http-deceiver@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" + integrity sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw== + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +http-errors@~1.6.2: + version "1.6.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" + integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A== + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.0" + statuses ">= 1.4.0 < 2" + +http-parser-js@>=0.5.1: + version "0.5.10" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075" + integrity sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA== + +http-proxy-middleware@^2.0.9: + version "2.0.9" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz#e9e63d68afaa4eee3d147f39149ab84c0c2815ef" + integrity sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q== + dependencies: + "@types/http-proxy" "^1.17.8" + http-proxy "^1.18.1" + is-glob "^4.0.1" + is-plain-obj "^3.0.0" + micromatch "^4.0.2" + +http-proxy@^1.18.1: + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== + dependencies: + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + +hyperdyperid@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/hyperdyperid/-/hyperdyperid-1.2.0.tgz#59668d323ada92228d2a869d3e474d5a33b69e6b" + integrity sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A== + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +import-local@^3.0.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" + integrity sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== + dependencies: + pkg-dir "^4.2.0" + resolve-cwd "^3.0.0" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== + +interpret@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4" + integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ== + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +ipaddr.js@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" + integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-core-module@^2.16.0: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + +is-docker@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-3.0.0.tgz#90093aa3106277d8a77a5910dbae71747e15a200" + integrity sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-inside-container@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-inside-container/-/is-inside-container-1.0.0.tgz#e81fba699662eb31dbdaf26766a61d4814717ea4" + integrity sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA== + dependencies: + is-docker "^3.0.0" + +is-network-error@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/is-network-error/-/is-network-error-1.3.0.tgz#2ce62cbca444abd506f8a900f39d20b898d37512" + integrity sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw== + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-plain-obj@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" + integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA== + +is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +is-wsl@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-3.1.0.tgz#e1c657e39c10090afcbedec61720f6b924c3cbd2" + integrity sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw== + dependencies: + is-inside-container "^1.0.0" + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +isbinaryfile@^4.0.8: + version "4.0.10" + resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3" + integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +jest-worker@^27.4.5: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" + integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== + dependencies: + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^8.0.0" + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +json-parse-even-better-errors@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== + optionalDependencies: + graceful-fs "^4.1.6" + +karma-chrome-launcher@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz#eb9c95024f2d6dfbb3748d3415ac9b381906b9a9" + integrity sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q== + dependencies: + which "^1.2.1" + +karma-mocha@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/karma-mocha/-/karma-mocha-2.0.1.tgz#4b0254a18dfee71bdbe6188d9a6861bf86b0cd7d" + integrity sha512-Tzd5HBjm8his2OA4bouAsATYEpZrp9vC7z5E5j4C5Of5Rrs1jY67RAwXNcVmd/Bnk1wgvQRou0zGVLey44G4tQ== + dependencies: + minimist "^1.2.3" + +karma-sourcemap-loader@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/karma-sourcemap-loader/-/karma-sourcemap-loader-0.4.0.tgz#b01d73f8f688f533bcc8f5d273d43458e13b5488" + integrity sha512-xCRL3/pmhAYF3I6qOrcn0uhbQevitc2DERMPH82FMnG+4WReoGcGFZb1pURf2a5apyrOHRdvD+O6K7NljqKHyA== + dependencies: + graceful-fs "^4.2.10" + +karma-webpack@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-5.0.1.tgz#4eafd31bbe684a747a6e8f3e4ad373e53979ced4" + integrity sha512-oo38O+P3W2mSPCSUrQdySSPv1LvPpXP+f+bBimNomS5sW+1V4SuhCuW8TfJzV+rDv921w2fDSDw0xJbPe6U+kQ== + dependencies: + glob "^7.1.3" + minimatch "^9.0.3" + webpack-merge "^4.1.5" + +karma@6.4.4: + version "6.4.4" + resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.4.tgz#dfa5a426cf5a8b53b43cd54ef0d0d09742351492" + integrity sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w== + dependencies: + "@colors/colors" "1.5.0" + body-parser "^1.19.0" + braces "^3.0.2" + chokidar "^3.5.1" + connect "^3.7.0" + di "^0.0.1" + dom-serialize "^2.2.1" + glob "^7.1.7" + graceful-fs "^4.2.6" + http-proxy "^1.18.1" + isbinaryfile "^4.0.8" + lodash "^4.17.21" + log4js "^6.4.1" + mime "^2.5.2" + minimatch "^3.0.4" + mkdirp "^0.5.5" + qjobs "^1.2.0" + range-parser "^1.2.1" + rimraf "^3.0.2" + socket.io "^4.7.2" + source-map "^0.6.1" + tmp "^0.2.1" + ua-parser-js "^0.7.30" + yargs "^16.1.1" + +kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + +kotlin-web-helpers@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/kotlin-web-helpers/-/kotlin-web-helpers-2.1.0.tgz#6cd4b0f0dc3baea163929c8638155b8d19c55a74" + integrity sha512-NAJhiNB84tnvJ5EQx7iER3GWw7rsTZkX9HVHZpe7E3dDBD/dhTzqgSwNU3MfQjniy2rB04bP24WM9Z32ntUWRg== + dependencies: + format-util "^1.0.5" + +launch-editor@^2.6.1: + version "2.11.1" + resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.11.1.tgz#61a0b7314a42fd84a6cbb564573d9e9ffcf3d72b" + integrity sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg== + dependencies: + picocolors "^1.1.1" + shell-quote "^1.8.3" + +loader-runner@^4.2.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.1.tgz#6c76ed29b0ccce9af379208299f07f876de737e3" + integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q== + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash@^4.17.15, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +log4js@^6.4.1: + version "6.9.1" + resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.9.1.tgz#aba5a3ff4e7872ae34f8b4c533706753709e38b6" + integrity sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g== + dependencies: + date-format "^4.0.14" + debug "^4.3.4" + flatted "^3.2.7" + rfdc "^1.3.0" + streamroller "^3.1.5" + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +memfs@^4.43.1: + version "4.49.0" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.49.0.tgz#bc35069570d41a31c62e31f1a6ec6057a8ea82f0" + integrity sha512-L9uC9vGuc4xFybbdOpRLoOAOq1YEBBsocCs5NVW32DfU+CZWWIn3OVF+lB8Gp4ttBVSMazwrTrjv8ussX/e3VQ== + dependencies: + "@jsonjoy.com/json-pack" "^1.11.0" + "@jsonjoy.com/util" "^1.9.0" + glob-to-regex.js "^1.0.1" + thingies "^2.5.0" + tree-dump "^1.0.3" + tslib "^2.0.0" + +merge-descriptors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +micromatch@^4.0.2: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +"mime-db@>= 1.43.0 < 2", mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + +mime-types@^2.1.27, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime-types@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.1.tgz#b1d94d6997a9b32fd69ebaed0db73de8acb519ce" + integrity sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA== + dependencies: + mime-db "^1.54.0" + +mime@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +mime@^2.5.2: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + +minimalistic-assert@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + +minimatch@^3.0.4, minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.3, minimatch@^9.0.4, minimatch@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.3, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +mkdirp@^0.5.5: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +mocha@11.7.1: + version "11.7.1" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.1.tgz#91948fecd624fb4bd154ed260b7e1ad3910d7c7a" + integrity sha512-5EK+Cty6KheMS/YLPPMJC64g5V61gIR25KsRItHw6x4hEKT6Njp1n9LOlH4gpevuwMVS66SXaBBpg+RWZkza4A== + dependencies: + browser-stdout "^1.3.1" + chokidar "^4.0.1" + debug "^4.3.5" + diff "^7.0.0" + escape-string-regexp "^4.0.0" + find-up "^5.0.0" + glob "^10.4.5" + he "^1.2.0" + js-yaml "^4.1.0" + log-symbols "^4.1.0" + minimatch "^9.0.5" + ms "^2.1.3" + picocolors "^1.1.1" + serialize-javascript "^6.0.2" + strip-json-comments "^3.1.1" + supports-color "^8.1.1" + workerpool "^9.2.0" + yargs "^17.7.2" + yargs-parser "^21.1.1" + yargs-unparser "^2.0.0" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.3, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +multicast-dns@^7.2.5: + version "7.2.5" + resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-7.2.5.tgz#77eb46057f4d7adbd16d9290fa7299f6fa64cced" + integrity sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg== + dependencies: + dns-packet "^5.2.2" + thunky "^1.0.2" + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +negotiator@~0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7" + integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== + +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +node-forge@^1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== + +node-releases@^2.0.21: + version "2.0.23" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.23.tgz#2ecf3d7ba571ece05c67c77e5b7b1b6fb9e18cea" + integrity sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg== + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +object-assign@^4: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.3: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +obuf@^1.0.0, obuf@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" + integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== + +on-finished@2.4.1, on-finished@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== + dependencies: + ee-first "1.1.1" + +on-headers@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.1.0.tgz#59da4f91c45f5f989c6e4bcedc5a3b0aed70ff65" + integrity sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A== + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +open@^10.0.3: + version "10.2.0" + resolved "https://registry.yarnpkg.com/open/-/open-10.2.0.tgz#b9d855be007620e80b6fb05fac98141fe62db73c" + integrity sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA== + dependencies: + default-browser "^5.2.1" + define-lazy-prop "^3.0.0" + is-inside-container "^1.0.0" + wsl-utils "^0.1.0" + +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-retry@^6.2.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-6.2.1.tgz#81828f8dc61c6ef5a800585491572cc9892703af" + integrity sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ== + dependencies: + "@types/retry" "0.12.2" + is-network-error "^1.0.0" + retry "^0.13.1" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parseurl@~1.3.2, parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" + integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== + +qjobs@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071" + integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg== + +qs@6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" + integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== + dependencies: + side-channel "^1.0.6" + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +range-parser@^1.2.1, range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +readable-stream@^2.0.1: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.0.6: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +rechoir@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22" + integrity sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ== + dependencies: + resolve "^1.20.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + dependencies: + resolve-from "^5.0.0" + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve@^1.20.0: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +retry@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + +rfdc@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" + integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +run-applescript@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/run-applescript/-/run-applescript-7.1.0.tgz#2e9e54c4664ec3106c5b5630e249d3d6595c4911" + integrity sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q== + +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +schema-utils@^4.0.0, schema-utils@^4.2.0, schema-utils@^4.3.0, schema-utils@^4.3.2: + version "4.3.3" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.3.tgz#5b1850912fa31df90716963d45d9121fdfc09f46" + integrity sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA== + dependencies: + "@types/json-schema" "^7.0.9" + ajv "^8.9.0" + ajv-formats "^2.1.1" + ajv-keywords "^5.1.0" + +select-hose@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" + integrity sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg== + +selfsigned@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.4.1.tgz#560d90565442a3ed35b674034cec4e95dceb4ae0" + integrity sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q== + dependencies: + "@types/node-forge" "^1.3.0" + node-forge "^1" + +send@0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" + integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +serialize-javascript@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + +serve-index@^1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" + integrity sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw== + dependencies: + accepts "~1.3.4" + batch "0.6.1" + debug "2.6.9" + escape-html "~1.0.3" + http-errors "~1.6.2" + mime-types "~2.1.17" + parseurl "~1.3.2" + +serve-static@1.16.2: + version "1.16.2" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296" + integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== + dependencies: + encodeurl "~2.0.0" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.19.0" + +setprototypeof@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" + integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +shallow-clone@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" + integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== + dependencies: + kind-of "^6.0.2" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +shell-quote@^1.8.3: + version "1.8.3" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b" + integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== + +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.0.6: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +socket.io-adapter@~2.5.2: + version "2.5.5" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz#c7a1f9c703d7756844751b6ff9abfc1780664082" + integrity sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg== + dependencies: + debug "~4.3.4" + ws "~8.17.1" + +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + +socket.io@^4.7.2: + version "4.8.1" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.8.1.tgz#fa0eaff965cc97fdf4245e8d4794618459f7558a" + integrity sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg== + dependencies: + accepts "~1.3.4" + base64id "~2.0.0" + cors "~2.8.5" + debug "~4.3.2" + engine.io "~6.6.0" + socket.io-adapter "~2.5.2" + socket.io-parser "~4.2.4" + +sockjs@^0.3.24: + version "0.3.24" + resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" + integrity sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ== + dependencies: + faye-websocket "^0.11.3" + uuid "^8.3.2" + websocket-driver "^0.7.4" + +source-map-js@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map-loader@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-5.0.0.tgz#f593a916e1cc54471cfc8851b905c8a845fc7e38" + integrity sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA== + dependencies: + iconv-lite "^0.6.3" + source-map-js "^1.0.2" + +source-map-support@~0.5.20: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0, source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +spdy-transport@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" + integrity sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw== + dependencies: + debug "^4.1.0" + detect-node "^2.0.4" + hpack.js "^2.1.6" + obuf "^1.1.2" + readable-stream "^3.0.6" + wbuf "^1.7.3" + +spdy@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/spdy/-/spdy-4.0.2.tgz#b74f466203a3eda452c02492b91fb9e84a27677b" + integrity sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA== + dependencies: + debug "^4.1.0" + handle-thing "^2.0.0" + http-deceiver "^1.2.7" + select-hose "^2.0.0" + spdy-transport "^3.0.0" + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +"statuses@>= 1.4.0 < 2", statuses@~1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + +streamroller@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.5.tgz#1263182329a45def1ffaef58d31b15d13d2ee7ff" + integrity sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw== + dependencies: + date-format "^4.0.14" + debug "^4.3.4" + fs-extra "^8.1.0" + +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.0.0, supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +tapable@^2.1.1, tapable@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" + integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== + +terser-webpack-plugin@^5.3.11: + version "5.3.14" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz#9031d48e57ab27567f02ace85c7d690db66c3e06" + integrity sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + jest-worker "^27.4.5" + schema-utils "^4.3.0" + serialize-javascript "^6.0.2" + terser "^5.31.1" + +terser@^5.31.1: + version "5.44.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.44.0.tgz#ebefb8e5b8579d93111bfdfc39d2cf63879f4a82" + integrity sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w== + dependencies: + "@jridgewell/source-map" "^0.3.3" + acorn "^8.15.0" + commander "^2.20.0" + source-map-support "~0.5.20" + +thingies@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/thingies/-/thingies-2.5.0.tgz#5f7b882c933b85989f8466b528a6247a6881e04f" + integrity sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw== + +thunky@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" + integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== + +tmp@^0.2.1: + version "0.2.5" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.5.tgz#b06bcd23f0f3c8357b426891726d16015abfd8f8" + integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +tree-dump@^1.0.3: + version "1.1.0" + resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.1.0.tgz#ab29129169dc46004414f5a9d4a3c6e89f13e8a4" + integrity sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA== + +tslib@^2.0.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +ua-parser-js@^0.7.30: + version "0.7.41" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.41.tgz#9f6dee58c389e8afababa62a4a2dc22edb69a452" + integrity sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg== + +undici-types@~7.14.0: + version "7.14.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.14.0.tgz#4c037b32ca4d7d62fae042174604341588bc0840" + integrity sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA== + +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +update-browserslist-db@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" + integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +vary@^1, vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +void-elements@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" + integrity sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung== + +watchpack@^2.4.1: + version "2.4.4" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.4.tgz#473bda72f0850453da6425081ea46fc0d7602947" + integrity sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" + +wbuf@^1.1.0, wbuf@^1.7.3: + version "1.7.3" + resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" + integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA== + dependencies: + minimalistic-assert "^1.0.0" + +webpack-cli@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-6.0.1.tgz#a1ce25da5ba077151afd73adfa12e208e5089207" + integrity sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw== + dependencies: + "@discoveryjs/json-ext" "^0.6.1" + "@webpack-cli/configtest" "^3.0.1" + "@webpack-cli/info" "^3.0.1" + "@webpack-cli/serve" "^3.0.1" + colorette "^2.0.14" + commander "^12.1.0" + cross-spawn "^7.0.3" + envinfo "^7.14.0" + fastest-levenshtein "^1.0.12" + import-local "^3.0.2" + interpret "^3.1.1" + rechoir "^0.8.0" + webpack-merge "^6.0.1" + +webpack-dev-middleware@^7.4.2: + version "7.4.5" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz#d4e8720aa29cb03bc158084a94edb4594e3b7ac0" + integrity sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA== + dependencies: + colorette "^2.0.10" + memfs "^4.43.1" + mime-types "^3.0.1" + on-finished "^2.4.1" + range-parser "^1.2.1" + schema-utils "^4.0.0" + +webpack-dev-server@5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz#96a143d50c58fef0c79107e61df911728d7ceb39" + integrity sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg== + dependencies: + "@types/bonjour" "^3.5.13" + "@types/connect-history-api-fallback" "^1.5.4" + "@types/express" "^4.17.21" + "@types/express-serve-static-core" "^4.17.21" + "@types/serve-index" "^1.9.4" + "@types/serve-static" "^1.15.5" + "@types/sockjs" "^0.3.36" + "@types/ws" "^8.5.10" + ansi-html-community "^0.0.8" + bonjour-service "^1.2.1" + chokidar "^3.6.0" + colorette "^2.0.10" + compression "^1.7.4" + connect-history-api-fallback "^2.0.0" + express "^4.21.2" + graceful-fs "^4.2.6" + http-proxy-middleware "^2.0.9" + ipaddr.js "^2.1.0" + launch-editor "^2.6.1" + open "^10.0.3" + p-retry "^6.2.0" + schema-utils "^4.2.0" + selfsigned "^2.4.1" + serve-index "^1.9.1" + sockjs "^0.3.24" + spdy "^4.0.2" + webpack-dev-middleware "^7.4.2" + ws "^8.18.0" + +webpack-merge@^4.1.5: + version "4.2.2" + resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-4.2.2.tgz#a27c52ea783d1398afd2087f547d7b9d2f43634d" + integrity sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g== + dependencies: + lodash "^4.17.15" + +webpack-merge@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-6.0.1.tgz#50c776868e080574725abc5869bd6e4ef0a16c6a" + integrity sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg== + dependencies: + clone-deep "^4.0.1" + flat "^5.0.2" + wildcard "^2.0.1" + +webpack-sources@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723" + integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg== + +webpack@5.100.2: + version "5.100.2" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.100.2.tgz#e2341facf9f7de1d702147c91bcb65b693adf9e8" + integrity sha512-QaNKAvGCDRh3wW1dsDjeMdDXwZm2vqq3zn6Pvq4rHOEOGSaUMgOOjG2Y9ZbIGzpfkJk9ZYTHpDqgDfeBDcnLaw== + dependencies: + "@types/eslint-scope" "^3.7.7" + "@types/estree" "^1.0.8" + "@types/json-schema" "^7.0.15" + "@webassemblyjs/ast" "^1.14.1" + "@webassemblyjs/wasm-edit" "^1.14.1" + "@webassemblyjs/wasm-parser" "^1.14.1" + acorn "^8.15.0" + acorn-import-phases "^1.0.3" + browserslist "^4.24.0" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.17.2" + es-module-lexer "^1.2.1" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.11" + json-parse-even-better-errors "^2.3.1" + loader-runner "^4.2.0" + mime-types "^2.1.27" + neo-async "^2.6.2" + schema-utils "^4.3.2" + tapable "^2.1.1" + terser-webpack-plugin "^5.3.11" + watchpack "^2.4.1" + webpack-sources "^3.3.3" + +websocket-driver@>=0.5.1, websocket-driver@^0.7.4: + version "0.7.4" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" + integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== + +which@^1.2.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wildcard@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" + integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ== + +workerpool@^9.2.0: + version "9.3.4" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-9.3.4.tgz#f6c92395b2141afd78e2a889e80cb338fe9fca41" + integrity sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +ws@^8.18.0: + version "8.18.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== + +ws@~8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + +wsl-utils@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/wsl-utils/-/wsl-utils-0.1.0.tgz#8783d4df671d4d50365be2ee4c71917a0557baab" + integrity sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw== + dependencies: + is-wsl "^3.1.0" + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs-unparser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@^16.1.1: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/LICENSE b/LICENSE index 5f50bda..4030423 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2024 Developer Chunk + Copyright 2026 Developer Chunk Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 6de1f54..b122075 100644 --- a/README.md +++ b/README.md @@ -1,768 +1,348 @@
-# 🚀 **JetCo Library** - -

- Kotlin Multiplatform - Jetpack Compose - Multi Platform -

- -

- - GitHub Release - - - Maven Central - - GitHub Stars - License -

- ---- +``` + ██╗███████╗████████╗ ██████╗ ██████╗ + ██║██╔════╝╚══██╔══╝██╔════╝██╔═══██╗ + ██║█████╗ ██║ ██║ ██║ ██║ +██ ██║██╔══╝ ██║ ██║ ██║ ██║ +╚█████╔╝███████╗ ██║ ╚██████╗╚██████╔╝ + ╚════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ + YOUR ULTIMATE COMPOSE COMPANION +``` -

🎯 Your Ultimate Compose Companion

+
-**JetCo** is a ready-to-go Compose library building awesome UI components for both **Android** and **KMP** (Kotlin Multiplatform). 💡 +
-

- ✨ Beautiful • ⚡ Fast • 🌍 Cross-Platform • 🎨 Customizable +

+Your ultimate Open-Source partner for crafting visually stunning, interactive, and efficient UI components across multiple platforms including Android, iOS, JVM, JS, and WASM.

-Your ultimate **Open-Source** partner for crafting visually stunning, interactive, and efficient UI components across multiple platforms including **Android**, **iOS**, **JVM**, **JS**, and **WASM**. Designed specifically for Jetpack Compose enthusiasts, JetCo is here to make your life easier, your apps prettier, and your users happier. 😊 +

- - Documentation + + Release - - Star on GitHub + + Maven

-
+
+ + + JetCo Documentation + -> 🚧 **KMP Documentation Pipeline**: Full Kotlin Multiplatform documentation is currently in development and will be live soon! For now, **KMP projects work identically** to Android projects with the same component functionality and API. The only differences are the **Gradle implementation** and **import statements** shown below. Stay tuned! 🚀 +
+
--- -
- -## �️ **Repository Structure** +## **Component Showcase** -Understanding the JetCo ecosystem at a glance +

+ ✨ Discover our beautiful collection of UI components ✨ +

-```mermaid -graph TB - A("JetCo Root Repository") --> B("JetCoKMP") - A --> C("jetco-android") - A --> D("assets") - A --> E("LICENSE & README") - - B --> B1("composeApp") - B --> B2("jetco - KMP Library") - B --> B3("iosApp") - B --> B4("gradle") - - B1 --> B1a("androidMain") - B1 --> B1b("commonMain") - B1 --> B1c("iosMain") - B1 --> B1d("jvmMain") - B1 --> B1e("jsMain") - B1 --> B1f("wasmJsMain") - - B2 --> B2a("commonMain") - B2 --> B2b("androidMain") - B2 --> B2c("iosMain") - B2 --> B2d("jvmMain") - B2 --> B2e("Tests") - - B2a --> B2a1("Charts") - B2a --> B2a2("Cards") - B2a --> B2a3("Steppers") - B2a --> B2a4("Controls") - - C --> C1("JetCoLibrary") - C1 --> C1a("app") - C1 --> C1b("jetco/ui") - C1 --> C1c("assets/images") - C1 --> C1d("gradle & build") - - C1b --> C1b1("Charts") - C1b --> C1b2("Cards") - C1b --> C1b3("Steppers") - C1b --> C1b4("Controls") - - D --> D1("images") - - %% Styling with light blue theme, blue borders, and increased spacing - classDef default fill:#E3F2FD,stroke:#1976D2,stroke-width:2px,color:#000000,rx:15,ry:15 - classDef rootStyle fill:#BBDEFB,stroke:#0D47A1,stroke-width:3px,color:#000000,rx:15,ry:15 - classDef kmpStyle fill:#E1F5FE,stroke:#0277BD,stroke-width:2px,color:#000000,rx:15,ry:15 - classDef androidStyle fill:#E8F5E8,stroke:#2E7D32,stroke-width:2px,color:#000000,rx:15,ry:15 - classDef libraryStyle fill:#FFF3E0,stroke:#E65100,stroke-width:2px,color:#000000,rx:15,ry:15 - classDef componentStyle fill:#F3E5F5,stroke:#6A1B9A,stroke-width:2px,color:#000000,rx:15,ry:15 - - class A rootStyle - class B,B1,B2,B3,B4 kmpStyle - class C,C1,C1a,C1c,C1d androidStyle - class B2a,B2b,B2c,B2d,C1b libraryStyle - class B2a1,B2a2,B2a3,B2a4,C1b1,C1b2,C1b3,C1b4 componentStyle -``` - -
- -## 🎨 **JetCo in Action** - -

✨ See the magic happen across all platforms! ✨

- -
+### **Charts & Graphs** + + + + + + + +
+ Live Line Chart +

+ Live Line Chart +
+ Multi Line Graph +

+ Multi Line Graph +
+ Candlestick Graph +

+ Candlestick Chart +
+ Pie Chart +

+ Pie Chart +
- Pie Chart -
🥧 Pie Chart -
Interactive data visualization + Column Bar Chart +

+ Column Bar Chart
- Column Bar Chart -
📊 Column Bar Chart -
Beautiful data bars + Extended Column Bar Chart +

+ Extended Bar Chart
- Extended Column Bar Chart -
🔥 Extended Bar Chart -
Advanced features + Group Column Bar Chart +

+ Group Bar Chart
- Group Column Bar Chart -
🤓 Group Bar Chart -
Multi-dataset comparison +
+ +
+ +### **UI Components** + + + + + + +
+ Custom Date Picker +

+ Custom Date Picker +
+ Ticket Card +

+ Ticket Card +
+ Curved Card +

+ Curved Card +
+ Curved Card Animation +

+ Curved Card Pro
- Ticket Card -
🎟️ Ticket Card -
Custom card design + Vertical Stepper +

+ Vertical Stepper
- Vertical Stepper -
⬇️ Vertical Stepper -
Timeline progression + Horizontal Stepper +

+ Horizontal Stepper
- Horizontal Stepper -
➡️ Horizontal Stepper -
Step-by-step progress + Compact Horizontal Stepper +

+ Compact Stepper
- Switch Button -
🔘 Switch Button -
Animated interactions + Switch Button +

+ Switch Button
- Compact Horizontal Stepper -
📏 Compact Horizontal Stepper -
Space-efficient progress + Animated Search Bar +

+ Animated Search Bar
- Curved Card -
🃏 Curved Card -
Elegant curved corners + Radial FAB Variant +

+ Radial FAB
- Curved Card -
🎨 Curved Card -
Creative card layouts + Stack FAB Variant +

+ Stack FAB
- + Morph FAB Variant +

+ Morph FAB
---- - -
- -## 🌍 **Platform Support** - -

Write once, run everywhere! JetCo supports all major platforms through Kotlin Multiplatform

+
+
-
+---
-| Platform | Status | Description | Target API | -|:--------:|:------:|:------------|:----------:| -| | ✅ **Full Support** | Native Android with Jetpack Compose optimizations | API 21+ | -| | ✅ **Full Support** | Native iOS through Compose Multiplatform | iOS 11+ | -| | ✅ **Full Support** | Windows, macOS, Linux applications | JVM 11+ | -| | ✅ **Full Support** | Modern web applications with Compose for Web | ES2015+ | -| | ✅ **Full Support** | High-performance WebAssembly applications | WASM 1.0 | - -
+## **Platform Support** -
+
-### 🎯 **Platform Highlights** - - - - - - - - +
- -
-Material Design 3
-Native performance & gestures -
- -
-Native iOS Feel
-Cupertino design system -
- -
-Cross-platform UI
-Windows, macOS, Linux -
- -
-Modern Web Apps
-PWA ready, responsive -
+ + + + + + +
+ +

+ Android +
API 21+ +
+ +

+ iOS +
iOS 11+ +
+ +

+ Desktop +
JVM 11+ +
+ +

+ Web +
ES2015+ +
+ +

+ WASM +
WASM 1.0 +
-
- ---- - -
- -## 🚀 **Installation** +
-

Get started in minutes! Choose your platform and follow the simple setup

+

+ Write once, run everywhere! +

-### 📱 **For Android - Jetpack Compose Projects** - -
-Click to expand Android setup - -Add this to your module's `build.gradle.kts`: - -```kotlin -dependencies { - implementation("com.developerstring.jetco:ui:1.0.0-beta.7") -} -``` - -
- -### 🌍 **For Kotlin Multiplatform Projects** - -
-🔥 Recommended - KMP Setup (Click to expand) - -Add this to your `commonMain` dependencies in `build.gradle.kts`: - -```kotlin -commonMain.dependencies { - implementation("com.developerstring.jetco-kmp:ui:1.0.0-beta.8") -} -``` - -> ⚡ **Quick Start**: Use the same component examples below for both Android Native and Kotlin Multiplatform! All functionality is identical. - -
- -### 🛠️ **Complete KMP Setup Example** - -
-Full configuration example - -```kotlin -kotlin { - // Android target - androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = "1.8" - } - } - } - - // iOS targets - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64() - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "ComposeApp" - isStatic = true - } - } - - // Desktop target - jvm("desktop") - - // Web target - js(IR) { - moduleName = "composeApp" - browser { - commonWebpackConfig { - outputFileName = "composeApp.js" - } - } - binaries.executable() - } - - // WASM target - wasmJs { - moduleName = "composeApp" - browser { - commonWebpackConfig { - outputFileName = "composeApp.js" - } - } - binaries.executable() - } - - sourceSets { - commonMain.dependencies { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material3) - implementation(compose.ui) - implementation(compose.components.resources) - - // 🎨 Add JetCo KMP - implementation("com.developerstring.jetco-kmp:ui:1.0.0-beta.8") - } - - androidMain.dependencies { - implementation(libs.compose.ui.tooling.preview) - implementation(libs.androidx.activity.compose) - } - - iosMain.dependencies { - // iOS specific dependencies - } - - desktopMain.dependencies { - implementation(compose.desktop.currentOs) - } - } -} -``` - -
+
+
---
-## 📊 **UI Components Gallery** - -

🎨 Rich, customizable components for every need

- -
- -
- -### 📈 **Charts & Data Visualization** +## **Documentation** -| Component | Description | Platforms | Status | -|:----------|:------------|:---------:|:------:| -| **🥧 Pie Chart** | Interactive pie charts with customizable slices and animations | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | -| **📊 Column Bar Chart** | Beautiful vertical bar charts with smooth transitions | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | -| **🔥 Extended Bar Chart** | Advanced bar charts with multiple datasets and styling | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | -| **🤓 Group Bar Chart** | Side-by-side comparison charts for multi-series data | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | - -### 🎴 **Cards & Layout** +
-| Component | Description | Platforms | Status | -|:----------|:------------|:---------:|:------:| -| **🎟️ Ticket Card** | Cinema-style cards with cutout design and dashed dividers | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | +

+ Get the full documentation, API references, code samples, and platform-specific guides at jetco.developerstring.com +

-### 🔄 **Navigation & Progress** +
-| Component | Description | Platforms | Status | -|:----------|:------------|:---------:|:------:| -| **⬇️ Vertical Stepper** | Timeline-style stepper with images and descriptions | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | -| **➡️ Horizontal Stepper** | Clean horizontal progress indicator | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | -| **⚡ Compact Stepper** | Minimal icon-only stepper for mobile interfaces | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | +

+ If you discover a security vulnerability in JetCo, please report it responsibly. Do not open a public GitHub issue for security vulnerabilities. Instead, refer this link to report the vulnerability JetCo vulnerability +

-### 🎛️ **Interactive Controls** +
-| Component | Description | Platforms | Status | -|:----------|:------------|:---------:|:------:| -| **🔘 Switch Button** | Animated toggle switch with custom styling | ![All Platforms](https://img.shields.io/badge/All-Platforms-success) | ✅ | +

+ Complete guides for Gradle setup, Kotlin Multiplatform configuration, and platform-specific implementations. +

-> 🚧 **Coming Soon:** Date Pickers, Floating Action Buttons, Bottom Sheets, and more! - +
+
+ ---
-## 🚀 **Getting Started** - -

⚡ From zero to awesome in 5 minutes!

- -
- -### 🎯 **Quick Start** +## 🤝 **Contribution** -### 📦 **Import Components** - -
-📝 Import statements for different setups - -```kotlin -// 🌍 For KMP projects (Recommended) -import com.developerstring.jetco_kmp.charts.piechart.PieChart -import com.developerstring.jetco_kmp.cards.ticket.TicketCard -import com.developerstring.jetco_kmp.cards.curved.CurvedCard -import com.developerstring.jetco_kmp.components.stepper.VerticalStepper -import com.developerstring.jetco_kmp.components.button.SwitchButton - -// 🤖 For Android-only projects -import com.developerstring.jetco.ui.charts.piechart.PieChart -import com.developerstring.jetco.ui.cards.ticket.TicketCard -import com.developerstring.jetco.ui.cards.curved.CurvedCard -import com.developerstring.jetco.ui.components.stepper.VerticalStepper -import com.developerstring.jetco.ui.components.button.SwitchButton -``` - -> 💡 **Note**: All components have **identical functionality and API** across both Android and KMP versions. Only the import path and Gradle dependency differ! - -
- -### 🎨 **Few Quick Examples** - -
-Pie Chart - -```kotlin -@Composable -fun MyAwesomeScreen() { - // 🎨 Use any JetCo component across all platforms! - PieChart( - data = listOf( - PieChartData( - label = "Android", - value = 40f, - color = Color(0xFF3DDC84) - ), - PieChartData( - label = "iOS", - value = 30f, - color = Color(0xFF007AFF) - ), - PieChartData( - label = "Web", - value = 20f, - color = Color(0xFFFF6B35) - ), - PieChartData( - label = "Desktop", - value = 10f, - color = Color(0xFFFFD23F) - ) - ), - modifier = Modifier - .size(300.dp) - .padding(16.dp), - animationDuration = 1000, - showLabels = true, - centerText = "JetCo" - ) -} -``` - -
- -
-Ticket Card - -```kotlin -@Composable -fun TicketExample() { - TicketCard( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - backgroundColor = Color(0xFF6200EE), - cornerRadius = 16.dp, - cutoutRadius = 24.dp - ) { - Column( - modifier = Modifier.padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "🎬 Movie Ticket", - style = MaterialTheme.typography.headlineSmall, - color = Color.White - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "JetCo: The Movie", - style = MaterialTheme.typography.bodyLarge, - color = Color.White.copy(alpha = 0.8f) - ) - } - } -} -``` - -
- -
-Curved Card - -```kotlin -@Composable -fun CurvedCardExample() { - CurvedCard( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - backgroundColor = Color(0xFF673AB7), - cornerRadius = 20.dp, - curveHeight = 40.dp, - curvePosition = CurvePosition.TOP_CENTER, - shadowElevation = 8.dp - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.Star, - contentDescription = "Premium", - tint = Color(0xFFFFD700), - modifier = Modifier.size(48.dp) - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Premium Membership", - style = MaterialTheme.typography.headlineSmall, - color = Color.White, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Unlock exclusive features with our curved card design", - style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.9f), - textAlign = TextAlign.Center - ) - } - } -} -``` +
-
- -
-Animated Switch Button - -```kotlin -@Composable -fun SwitchExample() { - var isChecked by remember { mutableStateOf(false) } - - SwitchButton( - checked = isChecked, - onCheckedChange = { isChecked = it }, - modifier = Modifier.padding(16.dp), - colors = SwitchButtonColors( - checkedColor = Color(0xFF4CAF50), - uncheckedColor = Color(0xFFE0E0E0), - thumbColor = Color.White - ), - animationDuration = 300 - ) -} -``` +

+ We'd love to see your contribution to JetCo! Whether it's fixing bugs, adding new components, improving documentation, or sharing ideas—every contribution makes a difference. Join us in building the ultimate Compose library for developers worldwide. +

-
+
-
+

+ Check out our contribution guidelines to get started. Found a bug or have a feature request? Open an issue—we're here to help! +

-### 📚 **Learn More** +
- - Documentation + + Star - - Examples + + Fork - - API + + Issue -> 🔥 **Coming Soon**: Dedicated KMP documentation with platform-specific examples and deployment guides! -
---- +
+
---
-## � **Why Choose JetCo?** +## **Show Some Love** -

🚀 The ultimate developer experience

+
+
+

+ If JetCo helped you build something amazing: +

- - - - - - - - - - - -
- -
-Write Once, Run Everywhere -
-Single codebase for Android, iOS, Web, Desktop & WASM -
- -
-Blazing Performance -
-Optimized for smooth 60fps animations -
- -
-Stunning Components -
-Pixel-perfect design with Material 3 -
- -
-Zero Learning Curve -
-Intuitive API, comprehensive docs -
- -
-Highly Flexible -
-Theme every component to match your brand -
-
-Always Updated
-Regular updates with latest Compose features -
- -
- -### 🎯 **Perfect For** - - -
+

+ Star the repo +   •   + Share on Twitter +   •   + Share on LinkedIn +

---- +
-
+

+ Made with ❤️ by the JetCo team +

-## 🚀 **What Makes JetCo Special?** +
-

✨ The features that set us apart

+ + +
-
- -| Feature | Description | Benefit | -|:--------|:------------|:--------| -| 🌐 **Kotlin Multiplatform** | One codebase for Android, iOS, Web, Desktop, and WASM | **50% less development time** | -| 🖌️ **Modern UI Components** | Charts, cards, steppers with beautiful animations | **Professional look out-of-the-box** | -| ⚡ **Compose Integration** | Seamless with Jetpack Compose and Compose Multiplatform | **Native performance everywhere** | -| 🛠️ **Infinite Customization** | Theme every pixel to match your brand identity | **Unique, branded experiences** | -| 🚀 **Performance Optimized** | Lightweight, efficient, 60fps smooth animations | **Happy users, better retention** | -| 📱 **Platform-Specific** | Tailored experiences respecting platform conventions | **Feels native on every platform** | -| 🔄 **Future-Ready** | Built for the next generation of Compose | **Investment protection** | -| 🆓 **Open Source** | MIT licensed, community-driven development | **Free forever, transparent** | - -
+
+
---
-## 🤝 **Join the JetCo Community** +

✨ Thanks to all our amazing contributors ✨

-

🌟 Be part of something amazing!

+
- - Star Us - - - Fork + + Contributors - - Report Bug - - - Discussions - - -

- -### 🎉 **Contributors Welcome!** - -We're always looking for passionate developers to join our mission of making beautiful UI accessible to everyone. - -Check out our [Contributing Guide](https://jetco.developerstring.com/community) and [Good First Issues](https://github.com/developerchunk/JetCo/labels/good%20first%20issue)! - -
- ---- - -
- -### 💝 **Show Some Love** - -

If JetCo helped you build something amazing, consider:

-⭐ Starring the repo • -🐦 Sharing on Twitter • -💼 Sharing on LinkedIn - -

Made with ❤️ by the JetCo team

- - - - +
+
- - diff --git a/assets/images/AnimatedSearchBar.gif b/assets/images/AnimatedSearchBar.gif new file mode 100644 index 0000000..a466ad0 Binary files /dev/null and b/assets/images/AnimatedSearchBar.gif differ diff --git a/assets/images/candlestick_chart.png b/assets/images/candlestick_chart.png new file mode 100644 index 0000000..7a7e666 Binary files /dev/null and b/assets/images/candlestick_chart.png differ diff --git a/assets/images/custom_date_picker.png b/assets/images/custom_date_picker.png new file mode 100644 index 0000000..f97e3f5 Binary files /dev/null and b/assets/images/custom_date_picker.png differ diff --git a/assets/images/date_picker.png b/assets/images/date_picker.png new file mode 100644 index 0000000..34fb338 Binary files /dev/null and b/assets/images/date_picker.png differ diff --git a/assets/images/date_range_picker.png b/assets/images/date_range_picker.png new file mode 100644 index 0000000..2273ebf Binary files /dev/null and b/assets/images/date_range_picker.png differ diff --git a/assets/images/date_time_picker.png b/assets/images/date_time_picker.png new file mode 100644 index 0000000..65b7121 Binary files /dev/null and b/assets/images/date_time_picker.png differ diff --git a/assets/images/fab_morph.gif b/assets/images/fab_morph.gif new file mode 100644 index 0000000..46c32b0 Binary files /dev/null and b/assets/images/fab_morph.gif differ diff --git a/assets/images/fab_radial.gif b/assets/images/fab_radial.gif new file mode 100644 index 0000000..c99fee4 Binary files /dev/null and b/assets/images/fab_radial.gif differ diff --git a/assets/images/fab_stack.gif b/assets/images/fab_stack.gif new file mode 100644 index 0000000..b37b14e Binary files /dev/null and b/assets/images/fab_stack.gif differ diff --git a/assets/images/line_graph.png b/assets/images/line_graph.png new file mode 100644 index 0000000..7b0fd98 Binary files /dev/null and b/assets/images/line_graph.png differ diff --git a/assets/images/line_graph_custom_marker.png b/assets/images/line_graph_custom_marker.png new file mode 100644 index 0000000..45998b6 Binary files /dev/null and b/assets/images/line_graph_custom_marker.png differ diff --git a/assets/images/line_graph_custom_popup.png b/assets/images/line_graph_custom_popup.png new file mode 100644 index 0000000..4c3b144 Binary files /dev/null and b/assets/images/line_graph_custom_popup.png differ diff --git a/assets/images/live_line_chart.gif b/assets/images/live_line_chart.gif new file mode 100644 index 0000000..ffb2e41 Binary files /dev/null and b/assets/images/live_line_chart.gif differ diff --git a/assets/images/month_picker.png b/assets/images/month_picker.png new file mode 100644 index 0000000..1e1d073 Binary files /dev/null and b/assets/images/month_picker.png differ diff --git a/assets/images/month_year_picker.png b/assets/images/month_year_picker.png new file mode 100644 index 0000000..e50d9d3 Binary files /dev/null and b/assets/images/month_year_picker.png differ diff --git a/assets/images/multi_line_graph.png b/assets/images/multi_line_graph.png new file mode 100644 index 0000000..77ff364 Binary files /dev/null and b/assets/images/multi_line_graph.png differ diff --git a/assets/images/time_picker.png b/assets/images/time_picker.png new file mode 100644 index 0000000..7d60221 Binary files /dev/null and b/assets/images/time_picker.png differ diff --git a/assets/images/time_picker_am_pm.png b/assets/images/time_picker_am_pm.png new file mode 100644 index 0000000..a1f5df3 Binary files /dev/null and b/assets/images/time_picker_am_pm.png differ diff --git a/assets/images/year_picker.png b/assets/images/year_picker.png new file mode 100644 index 0000000..522029b Binary files /dev/null and b/assets/images/year_picker.png differ diff --git a/jetco-android/JetCoLibrary/.gitignore b/jetco-android/JetCoLibrary/.gitignore index 4e46ec9..3e17efd 100644 --- a/jetco-android/JetCoLibrary/.gitignore +++ b/jetco-android/JetCoLibrary/.gitignore @@ -17,29 +17,4 @@ spare /.jreleaser gradle.properties /gradle.properties -/jetco/build - -# Additional sensitive files -*.keystore -*.jks -*.key -*.p12 -release.keystore -debug.keystore - -# Environment files -.env -.env.local -.env.production -.env.staging - -# API Keys and credentials -**/keys/ -**/secrets/ -api_keys.txt -config.properties - -# JReleaser -.jreleaser/ -jreleaser.yml -jreleaser.toml \ No newline at end of file +/jetco/build \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/.idea/deploymentTargetSelector.xml b/jetco-android/JetCoLibrary/.idea/deploymentTargetSelector.xml index f90f923..3ae18cc 100644 --- a/jetco-android/JetCoLibrary/.idea/deploymentTargetSelector.xml +++ b/jetco-android/JetCoLibrary/.idea/deploymentTargetSelector.xml @@ -4,7 +4,7 @@