diff --git a/COMPOSE_MIGRATION.md b/COMPOSE_MIGRATION.md new file mode 100644 index 000000000..c178d1972 --- /dev/null +++ b/COMPOSE_MIGRATION.md @@ -0,0 +1,203 @@ +# AndroidChart Compose Integration + +## Overview + +All chart classes from the `info.appdev.charting.charts` package have been successfully converted to Jetpack Compose composables. The implementation uses AndroidView wrappers to provide a Compose-friendly API while maintaining full compatibility with the existing chart rendering engine. + +## Usage Examples + +### Basic Line Chart + +```kotlin +@Composable +fun MyLineChart() { + val entries = remember { + listOf( + Entry(0f, 10f), + Entry(1f, 20f), + Entry(2f, 15f), + Entry(3f, 30f) + ) + } + + val dataSet = LineDataSet(entries, "Sample Data").apply { + color = Color.BLUE + setDrawCircles(true) + } + + val lineData = LineData(dataSet) + + LineChart( + data = lineData, + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + description = "Sales Over Time", + animationDuration = 1000, + onValueSelected = { entry, highlight -> + println("Selected: ${entry?.y}") + } + ) +} +``` + +### Bar Chart with Configuration + +```kotlin +@Composable +fun MyBarChart() { + val barData = remember { createBarData() } + + BarChart( + data = barData, + modifier = Modifier.fillMaxSize(), + description = "Monthly Revenue", + backgroundColor = Color(0xFFF5F5F5), + drawValueAboveBar = true, + animationDuration = 1500, + legend = { legend -> + legend.isEnabled = true + legend.textSize = 12f + }, + xAxisConfig = { xAxis -> + xAxis.position = XAxis.XAxisPosition.BOTTOM + xAxis.setDrawGridLines(false) + }, + leftAxisConfig = { axis -> + axis.axisMinimum = 0f + } + ) +} +``` + +### Pie Chart with Customization + +```kotlin +@Composable +fun MyPieChart() { + val pieData = remember { createPieData() } + + PieChart( + data = pieData, + modifier = Modifier + .fillMaxWidth() + .height(400.dp), + drawHoleEnabled = true, + holeRadius = 40f, + transparentCircleRadius = 45f, + centerText = "Total Sales", + rotationEnabled = true, + usePercentValuesEnabled = true, + animationDuration = 1200, + legend = { legend -> + legend.verticalAlignment = Legend.LegendVerticalAlignment.BOTTOM + legend.orientation = Legend.LegendOrientation.HORIZONTAL + } + ) +} +``` + +### Combined Chart + +```kotlin +@Composable +fun MyCombinedChart() { + val combinedData = remember { + CombinedData().apply { + setData(createLineData()) + setData(createBarData()) + } + } + + CombinedChart( + data = combinedData, + modifier = Modifier.fillMaxSize(), + drawOrder = arrayOf( + CombinedChart.DrawOrder.BAR, + CombinedChart.DrawOrder.LINE + ), + description = "Sales and Forecast", + animationDuration = 1000 + ) +} +``` + +### Stateful Chart with Updates + +```kotlin +@Composable +fun InteractiveLineChart() { + val state = rememberLineChartState() + var selectedValue by remember { mutableStateOf(null) } + + Column { + LineChart( + data = state.data, + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + state = state, + onValueSelected = { entry, _ -> + selectedValue = entry?.y + } + ) + + selectedValue?.let { value -> + Text("Selected: $value", modifier = Modifier.padding(16.dp)) + } + + Button( + onClick = { + state.data = generateNewData() + } + ) { + Text("Refresh Data") + } + } +} +``` + +## Migration from View-Based Charts + +### Before (View-Based) +```kotlin +AndroidView(factory = { context -> + LineChart(context).apply { + data = lineData + description.isEnabled = false + setTouchEnabled(true) + animateX(1000) + invalidate() + } +}) +``` + +### After (Compose) +```kotlin +LineChart( + data = lineData, + description = null, + touchEnabled = true, + animationDuration = 1000 +) +``` + +## Implementation Details + +### AndroidView Wrapper Pattern +Each composable uses the `AndroidView` wrapper to embed the existing View-based chart implementation. This provides: +- Immediate compatibility with existing rendering code +- Full feature support without rewriting rendering logic +- Efficient integration with Compose's recomposition system + +### Lifecycle Management +- `remember {}` - Creates chart instance once +- `DisposableEffect` - Cleans up chart resources on disposal +- `AndroidView.update {}` - Updates chart when parameters change + +### Data Flow +1. User provides chart data as composable parameter +2. `update` lambda calls `chart.setData(data)` +3. Chart configuration applied (colors, animations, axes) +4. `chart.invalidate()` triggers redraw +5. Recomposition on parameter changes updates the chart diff --git a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSet.kt b/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSet.kt index 49f016c13..c210bbf7e 100644 --- a/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSet.kt +++ b/chartLib/src/main/kotlin/info/appdev/charting/data/BaseDataSet.kt @@ -126,8 +126,11 @@ abstract class BaseDataSet() : IDataSet { mColors.add(value) } - override val colors: MutableList + override var colors: MutableList get() = mColors + set(value) { + mColors = value + } override fun getColorByIndex(index: Int): Int { return mColors[index % mColors.size] diff --git a/chartLibCompose/build.gradle.kts b/chartLibCompose/build.gradle.kts new file mode 100644 index 000000000..34187b2fe --- /dev/null +++ b/chartLibCompose/build.gradle.kts @@ -0,0 +1,118 @@ +import info.git.versionHelper.getVersionText +import org.gradle.kotlin.dsl.implementation +import java.net.URI + +plugins { + id("com.android.library") + id("maven-publish") + id("kotlin-android") + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" + id("com.vanniktech.maven.publish") version "0.34.0" +} + +android { + namespace = "info.appdev.charting" + defaultConfig { + minSdk = 23 + compileSdk = 36 + + // VERSION_NAME no longer available as of 4.1 + // https://issuetracker.google.com/issues/158695880 + buildConfigField("String", "VERSION_NAME", "\"${getVersionText()}\"") + + consumerProguardFiles.add(File("proguard-lib.pro")) + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildTypes { + release { + isMinifyEnabled = false + } + } + buildFeatures { + buildConfig = true + compose = true + } + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + testOptions { + unitTests.isReturnDefaultValues = true // this prevents "not mocked" error + } +} + +dependencies { + implementation("androidx.annotation:annotation:1.9.1") + implementation("androidx.core:core:1.17.0") + implementation("androidx.activity:activity-ktx:1.12.2") + implementation("com.github.AppDevNext.Logcat:LogcatCoreLib:3.4") + api(project(":chartLib")) + + // Compose dependencies + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.runtime:runtime") + implementation("androidx.compose.runtime:runtime-saveable") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + + testImplementation("junit:junit:4.13.2") +} + +tasks.register("androidSourcesJar") { + archiveClassifier.set("sources") + from(android.sourceSets["main"].java.srcDirs) +} + +group = "info.mxtracks" +var versionVersion = getVersionText() +println("Build version $versionVersion") + +mavenPublishing { + pom { + name = "Android Chart" + description = + "A powerful Android chart view/graph view library, supporting line- bar- pie- radar- bubble- and candlestick charts as well as scaling, dragging and animations" + inceptionYear = "2022" + url = "https://github.com/AppDevNext/AndroidChart/" + licenses { + license { + name = "The Apache License, Version 2.0" + url = "http://www.apache.org/licenses/LICENSE-2.0.txt" + distribution = "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + } + developers { + developer { + id = "AppDevNext" + name = "AppDevNext" + url = "https://github.com/AppDevNext/" + } + } + scm { + url = "https://github.com/AppDevNext/AndroidChart/" + connection = "scm:git:git://github.com/AppDevNext/AndroidChart.git" + developerConnection = "scm:git:ssh://git@github.com/AppDevNext/AndroidChart.git" + } + } + + // Github packages + repositories { + maven { + version = "$versionVersion-SNAPSHOT" + name = "GitHubPackages" + url = URI("https://maven.pkg.github.com/AppDevNext/AndroidChart") + credentials { + username = System.getenv("GITHUBACTOR") + password = System.getenv("GITHUBTOKEN") + } + } + } +} diff --git a/chartLibCompose/ic_launcher-web.png b/chartLibCompose/ic_launcher-web.png new file mode 100644 index 000000000..ef5592200 Binary files /dev/null and b/chartLibCompose/ic_launcher-web.png differ diff --git a/chartLibCompose/proguard-lib.pro b/chartLibCompose/proguard-lib.pro new file mode 100644 index 000000000..9a306f98d --- /dev/null +++ b/chartLibCompose/proguard-lib.pro @@ -0,0 +1,7 @@ +# Whitelist AndroidChart +# Preserve all public classes and methods + +-keep class info.appdev.charting.** { *; } +-keep public class info.appdev.charting.animation.* { + public protected *; +} diff --git a/chartLibCompose/src/main/AndroidManifest.xml b/chartLibCompose/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7dc9b158e --- /dev/null +++ b/chartLibCompose/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/BarChartComposable.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/BarChartComposable.kt new file mode 100644 index 000000000..189cb259e --- /dev/null +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/BarChartComposable.kt @@ -0,0 +1,169 @@ +package info.appdev.charting.compose + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import info.appdev.charting.animation.Easing +import info.appdev.charting.charts.BarChart +import info.appdev.charting.components.Legend +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.data.BarData +import info.appdev.charting.data.Entry +import info.appdev.charting.highlight.Highlight +import info.appdev.charting.listener.OnChartValueSelectedListener + +/** + * A Composable wrapper for [BarChart]. + * + * @param data The bar chart data to display + * @param modifier The modifier to be applied to the chart + * @param state The state holder for the chart + * @param onValueSelected Callback when a value is selected + * @param description Description text for the chart + * @param legend Configuration for the legend + * @param xAxisConfig Configuration for the X axis + * @param leftAxisConfig Configuration for the left Y axis + * @param rightAxisConfig Configuration for the right Y axis + * @param backgroundColor Background color for the chart + * @param gridBackgroundColor Grid background color + * @param drawGridBackground Whether to draw the grid background + * @param touchEnabled Whether touch gestures are enabled + * @param dragEnabled Whether drag gestures are enabled + * @param scaleEnabled Whether scale/zoom gestures are enabled + * @param scaleXEnabled Whether horizontal scale/zoom is enabled + * @param scaleYEnabled Whether vertical scale/zoom is enabled + * @param pinchZoomEnabled Whether pinch zoom is enabled + * @param doubleTapToZoomEnabled Whether double tap to zoom is enabled + * @param highlightPerTapEnabled Whether highlighting on tap is enabled + * @param highlightFullBarEnabled Whether to highlight full bar or single stack entry + * @param drawValueAboveBar Whether to draw values above bars + * @param drawBarShadow Whether to draw bar shadows + * @param fitBars Whether to fit bars within the chart viewport + * @param animationDuration Animation duration in milliseconds + * @param animationEasing Animation easing function + */ +@Composable +fun BarChart( + data: BarData?, + modifier: Modifier = Modifier, + state: BarChartState = rememberBarChartState(), + onValueSelected: ((Entry?, Highlight?) -> Unit)? = null, + description: String? = null, + legend: ((Legend) -> Unit)? = null, + xAxisConfig: ((XAxis) -> Unit)? = null, + leftAxisConfig: ((YAxis) -> Unit)? = null, + rightAxisConfig: ((YAxis) -> Unit)? = null, + backgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, + gridBackgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.LightGray, + drawGridBackground: Boolean = false, + touchEnabled: Boolean = true, + dragEnabled: Boolean = true, + scaleEnabled: Boolean = true, + scaleXEnabled: Boolean = true, + scaleYEnabled: Boolean = true, + pinchZoomEnabled: Boolean = true, + doubleTapToZoomEnabled: Boolean = true, + highlightPerTapEnabled: Boolean = true, + highlightFullBarEnabled: Boolean = false, + drawValueAboveBar: Boolean = true, + drawBarShadow: Boolean = false, + fitBars: Boolean = false, + animationDuration: Int = 0, + animationEasing: Easing.EasingFunction? = null, +) { + val context = LocalContext.current + + val chart = remember { + BarChart(context).apply { + this.description.isEnabled = false + } + } + + DisposableEffect(chart) { + onDispose { + chart.clear() + } + } + + AndroidView( + factory = { chart }, + modifier = modifier.fillMaxSize(), + update = { barChart -> + // Update data + barChart.setData(data) + + // Update configuration + barChart.setBackgroundColor(backgroundColor.toArgb()) + barChart.setDrawGridBackground(drawGridBackground) + barChart.setGridBackgroundColor(gridBackgroundColor.toArgb()) + + // Touch settings + barChart.setTouchEnabled(touchEnabled) + barChart.isDragEnabled = dragEnabled + barChart.setScaleEnabled(scaleEnabled) + barChart.isScaleXEnabled = scaleXEnabled + barChart.isScaleYEnabled = scaleYEnabled + barChart.setPinchZoom(pinchZoomEnabled) + barChart.isDoubleTapToZoomEnabled = doubleTapToZoomEnabled + barChart.isHighlightPerTapEnabled = highlightPerTapEnabled + + // Bar-specific settings + barChart.isHighlightFullBarEnabled = highlightFullBarEnabled + barChart.isDrawValueAboveBarEnabled = drawValueAboveBar + barChart.isDrawBarShadowEnabled = drawBarShadow + barChart.setFitBars(fitBars) + + // Description + barChart.description.let { desc -> + if (description != null) { + desc.isEnabled = true + desc.text = description + } else { + desc.isEnabled = false + } + } + + // Legend + legend?.invoke(barChart.legend) + + // Axes + xAxisConfig?.invoke(barChart.xAxis) + leftAxisConfig?.invoke(barChart.axisLeft) + rightAxisConfig?.invoke(barChart.axisRight) + + // Selection listener + if (onValueSelected != null) { + barChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(entry: Entry, highlight: Highlight) { + onValueSelected(entry, highlight) + } + + override fun onNothingSelected() { + onValueSelected(null, null) + } + }) + } else { + barChart.setOnChartValueSelectedListener(null) + } + + // Animation + if (animationDuration > 0) { + if (animationEasing != null) { + barChart.animateY(animationDuration, animationEasing) + } else { + barChart.animateY(animationDuration) + } + } + + // Refresh + barChart.invalidate() + } + ) +} + diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/BubbleChartComposable.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/BubbleChartComposable.kt new file mode 100644 index 000000000..d2328d02d --- /dev/null +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/BubbleChartComposable.kt @@ -0,0 +1,112 @@ +package info.appdev.charting.compose + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import info.appdev.charting.animation.Easing +import info.appdev.charting.charts.BubbleChart +import info.appdev.charting.components.Legend +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.data.BubbleData +import info.appdev.charting.data.Entry +import info.appdev.charting.highlight.Highlight +import info.appdev.charting.listener.OnChartValueSelectedListener + +/** + * A Composable wrapper for [BubbleChart]. + */ +@Composable +fun BubbleChart( + data: BubbleData?, + modifier: Modifier = Modifier, + state: BubbleChartState = rememberBubbleChartState(), + onValueSelected: ((Entry?, Highlight?) -> Unit)? = null, + description: String? = null, + legend: ((Legend) -> Unit)? = null, + xAxisConfig: ((XAxis) -> Unit)? = null, + leftAxisConfig: ((YAxis) -> Unit)? = null, + rightAxisConfig: ((YAxis) -> Unit)? = null, + backgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, + gridBackgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.LightGray, + drawGridBackground: Boolean = false, + touchEnabled: Boolean = true, + dragEnabled: Boolean = true, + scaleEnabled: Boolean = true, + scaleXEnabled: Boolean = true, + scaleYEnabled: Boolean = true, + pinchZoomEnabled: Boolean = true, + doubleTapToZoomEnabled: Boolean = true, + highlightPerTapEnabled: Boolean = true, + animationDuration: Int = 0, + animationEasing: Easing.EasingFunction? = null, +) { + val context = LocalContext.current + + val chart = remember { + BubbleChart(context).apply { + this.description.isEnabled = false + } + } + + DisposableEffect(chart) { + onDispose { chart.clear() } + } + + AndroidView( + factory = { chart }, + modifier = modifier.fillMaxSize(), + update = { bubbleChart -> + bubbleChart.setData(data) + + bubbleChart.setBackgroundColor(backgroundColor.toArgb()) + bubbleChart.setDrawGridBackground(drawGridBackground) + bubbleChart.setGridBackgroundColor(gridBackgroundColor.toArgb()) + bubbleChart.setTouchEnabled(touchEnabled) + bubbleChart.isDragEnabled = dragEnabled + bubbleChart.setScaleEnabled(scaleEnabled) + bubbleChart.isScaleXEnabled = scaleXEnabled + bubbleChart.isScaleYEnabled = scaleYEnabled + bubbleChart.setPinchZoom(pinchZoomEnabled) + bubbleChart.isDoubleTapToZoomEnabled = doubleTapToZoomEnabled + bubbleChart.isHighlightPerTapEnabled = highlightPerTapEnabled + + bubbleChart.description.let { desc -> + if (description != null) { + desc.isEnabled = true + desc.text = description + } else { + desc.isEnabled = false + } + } + + legend?.invoke(bubbleChart.legend) + xAxisConfig?.invoke(bubbleChart.xAxis) + leftAxisConfig?.invoke(bubbleChart.axisLeft) + rightAxisConfig?.invoke(bubbleChart.axisRight) + + if (onValueSelected != null) { + bubbleChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(entry: Entry, highlight: Highlight) = onValueSelected(entry, highlight) + override fun onNothingSelected() = onValueSelected(null, null) + }) + } + + if (animationDuration > 0) { + if (animationEasing != null) { + bubbleChart.animateXY(animationDuration, animationDuration, animationEasing, animationEasing) + } else { + bubbleChart.animateXY(animationDuration, animationDuration) + } + } + + bubbleChart.invalidate() + } + ) +} + diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/CandleStickChartComposable.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/CandleStickChartComposable.kt new file mode 100644 index 000000000..acda28816 --- /dev/null +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/CandleStickChartComposable.kt @@ -0,0 +1,112 @@ +package info.appdev.charting.compose + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import info.appdev.charting.animation.Easing +import info.appdev.charting.charts.CandleStickChart +import info.appdev.charting.components.Legend +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.data.CandleData +import info.appdev.charting.data.Entry +import info.appdev.charting.highlight.Highlight +import info.appdev.charting.listener.OnChartValueSelectedListener + +/** + * A Composable wrapper for [CandleStickChart]. + */ +@Composable +fun CandleStickChart( + data: CandleData?, + modifier: Modifier = Modifier, + state: CandleStickChartState = rememberCandleStickChartState(), + onValueSelected: ((Entry?, Highlight?) -> Unit)? = null, + description: String? = null, + legend: ((Legend) -> Unit)? = null, + xAxisConfig: ((XAxis) -> Unit)? = null, + leftAxisConfig: ((YAxis) -> Unit)? = null, + rightAxisConfig: ((YAxis) -> Unit)? = null, + backgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, + gridBackgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.LightGray, + drawGridBackground: Boolean = false, + touchEnabled: Boolean = true, + dragEnabled: Boolean = true, + scaleEnabled: Boolean = true, + scaleXEnabled: Boolean = true, + scaleYEnabled: Boolean = true, + pinchZoomEnabled: Boolean = true, + doubleTapToZoomEnabled: Boolean = true, + highlightPerTapEnabled: Boolean = true, + animationDuration: Int = 0, + animationEasing: Easing.EasingFunction? = null, +) { + val context = LocalContext.current + + val chart = remember { + CandleStickChart(context).apply { + this.description.isEnabled = false + } + } + + DisposableEffect(chart) { + onDispose { chart.clear() } + } + + AndroidView( + factory = { chart }, + modifier = modifier.fillMaxSize(), + update = { candleChart -> + candleChart.setData(data) + + candleChart.setBackgroundColor(backgroundColor.toArgb()) + candleChart.setDrawGridBackground(drawGridBackground) + candleChart.setGridBackgroundColor(gridBackgroundColor.toArgb()) + candleChart.setTouchEnabled(touchEnabled) + candleChart.isDragEnabled = dragEnabled + candleChart.setScaleEnabled(scaleEnabled) + candleChart.isScaleXEnabled = scaleXEnabled + candleChart.isScaleYEnabled = scaleYEnabled + candleChart.setPinchZoom(pinchZoomEnabled) + candleChart.isDoubleTapToZoomEnabled = doubleTapToZoomEnabled + candleChart.isHighlightPerTapEnabled = highlightPerTapEnabled + + candleChart.description.let { desc -> + if (description != null) { + desc.isEnabled = true + desc.text = description + } else { + desc.isEnabled = false + } + } + + legend?.invoke(candleChart.legend) + xAxisConfig?.invoke(candleChart.xAxis) + leftAxisConfig?.invoke(candleChart.axisLeft) + rightAxisConfig?.invoke(candleChart.axisRight) + + if (onValueSelected != null) { + candleChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(entry: Entry, highlight: Highlight) = onValueSelected(entry, highlight) + override fun onNothingSelected() = onValueSelected(null, null) + }) + } + + if (animationDuration > 0) { + if (animationEasing != null) { + candleChart.animateY(animationDuration, animationEasing) + } else { + candleChart.animateY(animationDuration) + } + } + + candleChart.invalidate() + } + ) +} + diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartState.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartState.kt new file mode 100644 index 000000000..529119767 --- /dev/null +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartState.kt @@ -0,0 +1,200 @@ +package info.appdev.charting.compose + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.setValue +import info.appdev.charting.charts.CombinedChart +import info.appdev.charting.charts.ScatterChart +import info.appdev.charting.data.* +import info.appdev.charting.highlight.Highlight + +/** + * Base state holder for all chart types in Compose. + * Manages chart data, highlighting, and configuration in a Compose-friendly way. + */ +@Stable +sealed class ChartState> { + var data by mutableStateOf(null) + var highlights by mutableStateOf>(emptyList()) + var isLogEnabled by mutableStateOf(false) + var description by mutableStateOf(null) + var touchEnabled by mutableStateOf(true) + var dragDecelerationEnabled by mutableStateOf(true) + var dragDecelerationFrictionCoef by mutableStateOf(0.9f) +} + +/** + * State holder for LineChart composable. + */ +@Stable +class LineChartState : ChartState() { + companion object { + val Saver: Saver = listSaver( + save = { state -> + listOf( + state.isLogEnabled, + state.description, + state.touchEnabled, + state.dragDecelerationEnabled, + state.dragDecelerationFrictionCoef + ) + }, + restore = { list -> + LineChartState().apply { + isLogEnabled = list[0] as Boolean + description = list[1] as String? + touchEnabled = list[2] as Boolean + dragDecelerationEnabled = list[3] as Boolean + dragDecelerationFrictionCoef = list[4] as Float + } + } + ) + } +} + +/** + * State holder for BarChart composable. + */ +@Stable +class BarChartState : ChartState() { + var isHighlightFullBarEnabled by mutableStateOf(false) + var isDrawValueAboveBarEnabled by mutableStateOf(true) + var isDrawBarShadowEnabled by mutableStateOf(false) + + companion object { + val Saver: Saver = listSaver( + save = { state -> + listOf( + state.isLogEnabled, + state.description, + state.touchEnabled, + state.isHighlightFullBarEnabled, + state.isDrawValueAboveBarEnabled, + state.isDrawBarShadowEnabled + ) + }, + restore = { list -> + BarChartState().apply { + isLogEnabled = list[0] as Boolean + description = list[1] as String? + touchEnabled = list[2] as Boolean + isHighlightFullBarEnabled = list[3] as Boolean + isDrawValueAboveBarEnabled = list[4] as Boolean + isDrawBarShadowEnabled = list[5] as Boolean + } + } + ) + } +} + +/** + * State holder for HorizontalBarChart composable. + */ +@Stable +class HorizontalBarChartState : ChartState() { + var isHighlightFullBarEnabled by mutableStateOf(false) + var isDrawValueAboveBarEnabled by mutableStateOf(true) + var isDrawBarShadowEnabled by mutableStateOf(false) +} + +/** + * State holder for PieChart composable. + */ +@Stable +class PieChartState : ChartState() { + var isDrawEntryLabelsEnabled by mutableStateOf(true) + var isDrawHoleEnabled by mutableStateOf(true) + var isDrawSlicesUnderHoleEnabled by mutableStateOf(false) + var isUsePercentValuesEnabled by mutableStateOf(false) + var isDrawRoundedSlicesEnabled by mutableStateOf(false) + var holeRadius by mutableStateOf(50f) + var transparentCircleRadius by mutableStateOf(55f) + var centerText by mutableStateOf("") + var rotationAngle by mutableStateOf(270f) + var isRotationEnabled by mutableStateOf(true) + + companion object { + val Saver: Saver = listSaver( + save = { state -> + listOf( + state.isLogEnabled, + state.description, + state.isDrawEntryLabelsEnabled, + state.isDrawHoleEnabled, + state.holeRadius, + state.transparentCircleRadius, + state.centerText.toString(), + state.rotationAngle, + state.isRotationEnabled + ) + }, + restore = { list -> + PieChartState().apply { + isLogEnabled = list[0] as Boolean + description = list[1] as String? + isDrawEntryLabelsEnabled = list[2] as Boolean + isDrawHoleEnabled = list[3] as Boolean + holeRadius = list[4] as Float + transparentCircleRadius = list[5] as Float + centerText = list[6] as String + rotationAngle = list[7] as Float + isRotationEnabled = list[8] as Boolean + } + } + ) + } +} + +/** + * State holder for RadarChart composable. + */ +@Stable +class RadarChartState : ChartState() { + var webLineWidth by mutableStateOf(2.5f) + var webLineWidthInner by mutableStateOf(1.5f) + var webAlpha by mutableStateOf(150) + var skipWebLineCount by mutableStateOf(0) +} + +/** + * State holder for ScatterChart composable. + */ +@Stable +class ScatterChartState : ChartState() { + var scaleType by mutableStateOf(ScatterChart.ScatterShape.CIRCLE) +} + +/** + * State holder for BubbleChart composable. + */ +@Stable +class BubbleChartState : ChartState() + +/** + * State holder for CandleStickChart composable. + */ +@Stable +class CandleStickChartState : ChartState() + +/** + * State holder for CombinedChart composable. + */ +@Stable +class CombinedChartState : ChartState() { + var drawOrder by mutableStateOf( + arrayOf( + CombinedChart.DrawOrder.BAR, + CombinedChart.DrawOrder.BUBBLE, + CombinedChart.DrawOrder.LINE, + CombinedChart.DrawOrder.CANDLE, + CombinedChart.DrawOrder.SCATTER + ) + ) + var isDrawBarShadowEnabled by mutableStateOf(false) + var isHighlightFullBarEnabled by mutableStateOf(false) + var isDrawValueAboveBarEnabled by mutableStateOf(true) +} + diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartStateRemember.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartStateRemember.kt new file mode 100644 index 000000000..3e21d9e64 --- /dev/null +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ChartStateRemember.kt @@ -0,0 +1,99 @@ +package info.appdev.charting.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable + +/** + * Remember a [LineChartState] across recompositions. + * The state will be saved and restored across configuration changes. + */ +@Composable +fun rememberLineChartState(): LineChartState { + return rememberSaveable(saver = LineChartState.Saver) { + LineChartState() + } +} + +/** + * Remember a [BarChartState] across recompositions. + * The state will be saved and restored across configuration changes. + */ +@Composable +fun rememberBarChartState(): BarChartState { + return rememberSaveable(saver = BarChartState.Saver) { + BarChartState() + } +} + +/** + * Remember a [HorizontalBarChartState] across recompositions. + */ +@Composable +fun rememberHorizontalBarChartState(): HorizontalBarChartState { + return remember { + HorizontalBarChartState() + } +} + +/** + * Remember a [PieChartState] across recompositions. + * The state will be saved and restored across configuration changes. + */ +@Composable +fun rememberPieChartState(): PieChartState { + return rememberSaveable(saver = PieChartState.Saver) { + PieChartState() + } +} + +/** + * Remember a [RadarChartState] across recompositions. + */ +@Composable +fun rememberRadarChartState(): RadarChartState { + return remember { + RadarChartState() + } +} + +/** + * Remember a [ScatterChartState] across recompositions. + */ +@Composable +fun rememberScatterChartState(): ScatterChartState { + return remember { + ScatterChartState() + } +} + +/** + * Remember a [BubbleChartState] across recompositions. + */ +@Composable +fun rememberBubbleChartState(): BubbleChartState { + return remember { + BubbleChartState() + } +} + +/** + * Remember a [CandleStickChartState] across recompositions. + */ +@Composable +fun rememberCandleStickChartState(): CandleStickChartState { + return remember { + CandleStickChartState() + } +} + +/** + * Remember a [CombinedChartState] across recompositions. + */ +@Composable +fun rememberCombinedChartState(): CombinedChartState { + return remember { + CombinedChartState() + } +} + diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/CombinedChartComposable.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/CombinedChartComposable.kt new file mode 100644 index 000000000..245e98d99 --- /dev/null +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/CombinedChartComposable.kt @@ -0,0 +1,128 @@ +package info.appdev.charting.compose + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import info.appdev.charting.animation.Easing +import info.appdev.charting.charts.CombinedChart +import info.appdev.charting.components.Legend +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.data.CombinedData +import info.appdev.charting.data.Entry +import info.appdev.charting.highlight.Highlight +import info.appdev.charting.listener.OnChartValueSelectedListener + +/** + * A Composable wrapper for [CombinedChart]. + */ +@Composable +fun CombinedChart( + data: CombinedData?, + modifier: Modifier = Modifier, + state: CombinedChartState = rememberCombinedChartState(), + onValueSelected: ((Entry?, Highlight?) -> Unit)? = null, + description: String? = null, + legend: ((Legend) -> Unit)? = null, + xAxisConfig: ((XAxis) -> Unit)? = null, + leftAxisConfig: ((YAxis) -> Unit)? = null, + rightAxisConfig: ((YAxis) -> Unit)? = null, + backgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, + gridBackgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.LightGray, + drawGridBackground: Boolean = false, + touchEnabled: Boolean = true, + dragEnabled: Boolean = true, + scaleEnabled: Boolean = true, + scaleXEnabled: Boolean = true, + scaleYEnabled: Boolean = true, + pinchZoomEnabled: Boolean = true, + doubleTapToZoomEnabled: Boolean = true, + highlightPerTapEnabled: Boolean = true, + drawOrder: Array = arrayOf( + CombinedChart.DrawOrder.BAR, + CombinedChart.DrawOrder.BUBBLE, + CombinedChart.DrawOrder.LINE, + CombinedChart.DrawOrder.CANDLE, + CombinedChart.DrawOrder.SCATTER + ), + drawBarShadow: Boolean = false, + highlightFullBarEnabled: Boolean = false, + drawValueAboveBar: Boolean = true, + animationDuration: Int = 0, + animationEasing: Easing.EasingFunction? = null, +) { + val context = LocalContext.current + + val chart = remember { + CombinedChart(context).apply { + this.description.isEnabled = false + } + } + + DisposableEffect(chart) { + onDispose { chart.clear() } + } + + AndroidView( + factory = { chart }, + modifier = modifier.fillMaxSize(), + update = { combinedChart -> + combinedChart.setData(data) + + combinedChart.setBackgroundColor(backgroundColor.toArgb()) + combinedChart.setDrawGridBackground(drawGridBackground) + combinedChart.setGridBackgroundColor(gridBackgroundColor.toArgb()) + combinedChart.setTouchEnabled(touchEnabled) + combinedChart.isDragEnabled = dragEnabled + combinedChart.setScaleEnabled(scaleEnabled) + combinedChart.isScaleXEnabled = scaleXEnabled + combinedChart.isScaleYEnabled = scaleYEnabled + combinedChart.setPinchZoom(pinchZoomEnabled) + combinedChart.isDoubleTapToZoomEnabled = doubleTapToZoomEnabled + combinedChart.isHighlightPerTapEnabled = highlightPerTapEnabled + + // Combined-specific settings + combinedChart.drawOrder = drawOrder.toMutableList() + combinedChart.isDrawBarShadowEnabled = drawBarShadow + combinedChart.isHighlightFullBarEnabled = highlightFullBarEnabled + combinedChart.isDrawValueAboveBarEnabled = drawValueAboveBar + + combinedChart.description.let { desc -> + if (description != null) { + desc.isEnabled = true + desc.text = description + } else { + desc.isEnabled = false + } + } + + legend?.invoke(combinedChart.legend) + xAxisConfig?.invoke(combinedChart.xAxis) + leftAxisConfig?.invoke(combinedChart.axisLeft) + rightAxisConfig?.invoke(combinedChart.axisRight) + + if (onValueSelected != null) { + combinedChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(entry: Entry, highlight: Highlight) = onValueSelected(entry, highlight) + override fun onNothingSelected() = onValueSelected(null, null) + }) + } + + if (animationDuration > 0) { + if (animationEasing != null) { + combinedChart.animateXY(animationDuration, animationDuration, animationEasing, animationEasing) + } else { + combinedChart.animateXY(animationDuration, animationDuration) + } + } + + combinedChart.invalidate() + } + ) +} + diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/HorizontalBarChartComposable.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/HorizontalBarChartComposable.kt new file mode 100644 index 000000000..a19295a09 --- /dev/null +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/HorizontalBarChartComposable.kt @@ -0,0 +1,108 @@ +package info.appdev.charting.compose + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import info.appdev.charting.animation.Easing +import info.appdev.charting.charts.HorizontalBarChart +import info.appdev.charting.components.Legend +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.data.BarData +import info.appdev.charting.data.Entry +import info.appdev.charting.highlight.Highlight +import info.appdev.charting.listener.OnChartValueSelectedListener + +/** + * A Composable wrapper for [HorizontalBarChart]. + */ +@Composable +fun HorizontalBarChart( + data: BarData?, + modifier: Modifier = Modifier, + state: HorizontalBarChartState = rememberHorizontalBarChartState(), + onValueSelected: ((Entry?, Highlight?) -> Unit)? = null, + description: String? = null, + legend: ((Legend) -> Unit)? = null, + xAxisConfig: ((XAxis) -> Unit)? = null, + leftAxisConfig: ((YAxis) -> Unit)? = null, + rightAxisConfig: ((YAxis) -> Unit)? = null, + backgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, + gridBackgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.LightGray, + drawGridBackground: Boolean = false, + touchEnabled: Boolean = true, + dragEnabled: Boolean = true, + scaleEnabled: Boolean = true, + highlightFullBarEnabled: Boolean = false, + drawValueAboveBar: Boolean = true, + drawBarShadow: Boolean = false, + animationDuration: Int = 0, + animationEasing: Easing.EasingFunction? = null, +) { + val context = LocalContext.current + + val chart = remember { + HorizontalBarChart(context).apply { + this.description.isEnabled = false + } + } + + DisposableEffect(chart) { + onDispose { chart.clear() } + } + + AndroidView( + factory = { chart }, + modifier = modifier.fillMaxSize(), + update = { horizontalBarChart -> + horizontalBarChart.setData(data) + + horizontalBarChart.setBackgroundColor(backgroundColor.toArgb()) + horizontalBarChart.setDrawGridBackground(drawGridBackground) + horizontalBarChart.setGridBackgroundColor(gridBackgroundColor.toArgb()) + horizontalBarChart.setTouchEnabled(touchEnabled) + horizontalBarChart.isDragEnabled = dragEnabled + horizontalBarChart.setScaleEnabled(scaleEnabled) + horizontalBarChart.isHighlightFullBarEnabled = highlightFullBarEnabled + horizontalBarChart.isDrawValueAboveBarEnabled = drawValueAboveBar + horizontalBarChart.isDrawBarShadowEnabled = drawBarShadow + + horizontalBarChart.description.let { desc -> + if (description != null) { + desc.isEnabled = true + desc.text = description + } else { + desc.isEnabled = false + } + } + + legend?.invoke(horizontalBarChart.legend) + xAxisConfig?.invoke(horizontalBarChart.xAxis) + leftAxisConfig?.invoke(horizontalBarChart.axisLeft) + rightAxisConfig?.invoke(horizontalBarChart.axisRight) + + if (onValueSelected != null) { + horizontalBarChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(entry: Entry, highlight: Highlight) = onValueSelected(entry, highlight) + override fun onNothingSelected() = onValueSelected(null, null) + }) + } + + if (animationDuration > 0) { + if (animationEasing != null) { + horizontalBarChart.animateY(animationDuration, animationEasing) + } else { + horizontalBarChart.animateY(animationDuration) + } + } + + horizontalBarChart.invalidate() + } + ) +} + diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/LineChartComposable.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/LineChartComposable.kt new file mode 100644 index 000000000..fef573b8d --- /dev/null +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/LineChartComposable.kt @@ -0,0 +1,177 @@ +package info.appdev.charting.compose + +import android.graphics.Color +import android.graphics.Typeface +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import info.appdev.charting.animation.Easing +import info.appdev.charting.charts.LineChart +import info.appdev.charting.components.Description +import info.appdev.charting.components.Legend +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.data.Entry +import info.appdev.charting.data.LineData +import info.appdev.charting.highlight.Highlight +import info.appdev.charting.listener.OnChartValueSelectedListener + +/** + * A Composable wrapper for [LineChart]. + * + * @param data The line chart data to display + * @param modifier The modifier to be applied to the chart + * @param state The state holder for the chart + * @param onValueSelected Callback when a value is selected + * @param description Description text for the chart + * @param legend Configuration for the legend + * @param xAxisConfig Configuration for the X axis + * @param leftAxisConfig Configuration for the left Y axis + * @param rightAxisConfig Configuration for the right Y axis + * @param backgroundColor Background color for the chart + * @param gridBackgroundColor Grid background color + * @param drawGridBackground Whether to draw the grid background + * @param touchEnabled Whether touch gestures are enabled + * @param dragEnabled Whether drag gestures are enabled + * @param scaleEnabled Whether scale/zoom gestures are enabled + * @param scaleXEnabled Whether horizontal scale/zoom is enabled + * @param scaleYEnabled Whether vertical scale/zoom is enabled + * @param pinchZoomEnabled Whether pinch zoom is enabled + * @param doubleTapToZoomEnabled Whether double tap to zoom is enabled + * @param highlightPerTapEnabled Whether highlighting on tap is enabled + * @param dragDecelerationEnabled Whether drag deceleration is enabled + * @param dragDecelerationFrictionCoef Drag deceleration friction coefficient + * @param maxVisibleValueCount Maximum number of values to display + * @param autoScaleMinMaxEnabled Whether to auto-scale min/max values + * @param keepPositionOnRotation Whether to keep position on rotation + * @param animationDuration Animation duration in milliseconds + * @param animationEasing Animation easing function + */ +@Composable +fun LineChart( + data: LineData?, + modifier: Modifier = Modifier, + state: LineChartState = rememberLineChartState(), + onValueSelected: ((Entry?, Highlight?) -> Unit)? = null, + description: String? = null, + legend: ((Legend) -> Unit)? = null, + xAxisConfig: ((XAxis) -> Unit)? = null, + leftAxisConfig: ((YAxis) -> Unit)? = null, + rightAxisConfig: ((YAxis) -> Unit)? = null, + backgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, + gridBackgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.LightGray, + drawGridBackground: Boolean = false, + touchEnabled: Boolean = true, + dragEnabled: Boolean = true, + scaleEnabled: Boolean = true, + scaleXEnabled: Boolean = true, + scaleYEnabled: Boolean = true, + pinchZoomEnabled: Boolean = true, + doubleTapToZoomEnabled: Boolean = true, + highlightPerTapEnabled: Boolean = true, + dragDecelerationEnabled: Boolean = true, + dragDecelerationFrictionCoef: Float = 0.9f, + maxVisibleValueCount: Int = 100, + autoScaleMinMaxEnabled: Boolean = false, + keepPositionOnRotation: Boolean = false, + animationDuration: Int = 0, + animationEasing: Easing.EasingFunction? = null, +) { + val context = LocalContext.current + + val chart = remember { + LineChart(context).apply { + // Initial setup + this.description.isEnabled = false + } + } + + DisposableEffect(chart) { + onDispose { + // Clean up chart resources + chart.clear() + } + } + + AndroidView( + factory = { chart }, + modifier = modifier.fillMaxSize(), + update = { lineChart -> + // Update data + lineChart.setData(data) + + // Update configuration + lineChart.setBackgroundColor(backgroundColor.toArgb()) + lineChart.setDrawGridBackground(drawGridBackground) + lineChart.setGridBackgroundColor(gridBackgroundColor.toArgb()) + + // Touch settings + lineChart.setTouchEnabled(touchEnabled) + lineChart.isDragEnabled = dragEnabled + lineChart.setScaleEnabled(scaleEnabled) + lineChart.isScaleXEnabled = scaleXEnabled + lineChart.isScaleYEnabled = scaleYEnabled + lineChart.setPinchZoom(pinchZoomEnabled) + lineChart.isDoubleTapToZoomEnabled = doubleTapToZoomEnabled + lineChart.isHighlightPerTapEnabled = highlightPerTapEnabled + lineChart.isDragDecelerationEnabled = dragDecelerationEnabled + lineChart.dragDecelerationFrictionCoef = dragDecelerationFrictionCoef + + // Display settings + lineChart.setMaxVisibleValueCount(maxVisibleValueCount) + lineChart.isAutoScaleMinMaxEnabled = autoScaleMinMaxEnabled + lineChart.isKeepPositionOnRotation = keepPositionOnRotation + + // Description + lineChart.description.let { desc -> + if (description != null) { + desc.isEnabled = true + desc.text = description + } else { + desc.isEnabled = false + } + } + + // Legend + legend?.invoke(lineChart.legend) + + // Axes + xAxisConfig?.invoke(lineChart.xAxis) + leftAxisConfig?.invoke(lineChart.axisLeft) + rightAxisConfig?.invoke(lineChart.axisRight) + + // Selection listener + if (onValueSelected != null) { + lineChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(entry: Entry, highlight: Highlight) { + onValueSelected(entry, highlight) + } + + override fun onNothingSelected() { + onValueSelected(null, null) + } + }) + } else { + lineChart.setOnChartValueSelectedListener(null) + } + + // Animation + if (animationDuration > 0) { + if (animationEasing != null) { + lineChart.animateX(animationDuration, animationEasing) + } else { + lineChart.animateX(animationDuration) + } + } + + // Refresh + lineChart.invalidate() + } + ) +} + diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/PieChartComposable.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/PieChartComposable.kt new file mode 100644 index 000000000..57f23680a --- /dev/null +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/PieChartComposable.kt @@ -0,0 +1,153 @@ +package info.appdev.charting.compose + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import info.appdev.charting.animation.Easing +import info.appdev.charting.charts.PieChart +import info.appdev.charting.components.Legend +import info.appdev.charting.data.Entry +import info.appdev.charting.data.PieData +import info.appdev.charting.highlight.Highlight +import info.appdev.charting.listener.OnChartValueSelectedListener + +/** + * A Composable wrapper for [PieChart]. + * + * @param data The pie chart data to display + * @param modifier The modifier to be applied to the chart + * @param state The state holder for the chart + * @param onValueSelected Callback when a value is selected + * @param description Description text for the chart + * @param legend Configuration for the legend + * @param backgroundColor Background color for the chart + * @param touchEnabled Whether touch gestures are enabled + * @param rotationEnabled Whether rotation is enabled + * @param highlightPerTapEnabled Whether highlighting on tap is enabled + * @param drawEntryLabelsEnabled Whether to draw entry labels + * @param drawHoleEnabled Whether to draw a hole in the center + * @param drawSlicesUnderHoleEnabled Whether to draw slices under the hole + * @param usePercentValuesEnabled Whether to use percentage values + * @param drawRoundedSlicesEnabled Whether to draw rounded slices + * @param holeRadius Radius of the center hole (0-100) + * @param transparentCircleRadius Radius of the transparent circle (0-100) + * @param centerText Text to display in the center + * @param rotationAngle Starting rotation angle in degrees + * @param minAngleForSlices Minimum angle for slices + * @param animationDuration Animation duration in milliseconds + * @param animationEasing Animation easing function + */ +@Composable +fun PieChart( + data: PieData?, + modifier: Modifier = Modifier, + state: PieChartState = rememberPieChartState(), + onValueSelected: ((Entry?, Highlight?) -> Unit)? = null, + description: String? = null, + legend: ((Legend) -> Unit)? = null, + backgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, + touchEnabled: Boolean = true, + rotationEnabled: Boolean = true, + highlightPerTapEnabled: Boolean = true, + drawEntryLabelsEnabled: Boolean = true, + drawHoleEnabled: Boolean = true, + drawSlicesUnderHoleEnabled: Boolean = false, + usePercentValuesEnabled: Boolean = false, + drawRoundedSlicesEnabled: Boolean = false, + holeRadius: Float = 50f, + transparentCircleRadius: Float = 55f, + centerText: CharSequence = "", + rotationAngle: Float = 270f, + minAngleForSlices: Float = 0f, + animationDuration: Int = 0, + animationEasing: Easing.EasingFunction? = null, +) { + val context = LocalContext.current + + val chart = remember { + PieChart(context).apply { + this.description.isEnabled = false + } + } + + DisposableEffect(chart) { + onDispose { + chart.clear() + } + } + + AndroidView( + factory = { chart }, + modifier = modifier.fillMaxSize(), + update = { pieChart -> + // Update data + pieChart.setData(data) + + // Update configuration + pieChart.setBackgroundColor(backgroundColor.toArgb()) + + // Touch settings + pieChart.setTouchEnabled(touchEnabled) + pieChart.isRotationEnabled = rotationEnabled + pieChart.isHighlightPerTapEnabled = highlightPerTapEnabled + + // Pie-specific settings + pieChart.setDrawEntryLabels(drawEntryLabelsEnabled) + pieChart.isDrawHoleEnabled = drawHoleEnabled + pieChart.setDrawSlicesUnderHole(drawSlicesUnderHoleEnabled) + pieChart.setUsePercentValues(usePercentValuesEnabled) + pieChart.setDrawRoundedSlices(drawRoundedSlicesEnabled) + pieChart.holeRadius = holeRadius + pieChart.transparentCircleRadius = transparentCircleRadius + pieChart.centerText = centerText + pieChart.rotationAngle = rotationAngle + pieChart.minAngleForSlices = minAngleForSlices + + // Description + pieChart.description.let { desc -> + if (description != null) { + desc.isEnabled = true + desc.text = description + } else { + desc.isEnabled = false + } + } + + // Legend + legend?.invoke(pieChart.legend) + + // Selection listener + if (onValueSelected != null) { + pieChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(entry: Entry, highlight: Highlight) { + onValueSelected(entry, highlight) + } + + override fun onNothingSelected() { + onValueSelected(null, null) + } + }) + } else { + pieChart.setOnChartValueSelectedListener(null) + } + + // Animation + if (animationDuration > 0) { + if (animationEasing != null) { + pieChart.spin(animationDuration, rotationAngle, rotationAngle + 360f, animationEasing) + } else { + pieChart.animateY(animationDuration) + } + } + + // Refresh + pieChart.invalidate() + } + ) +} + diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/RadarChartComposable.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/RadarChartComposable.kt new file mode 100644 index 000000000..f28aa0ee5 --- /dev/null +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/RadarChartComposable.kt @@ -0,0 +1,102 @@ +package info.appdev.charting.compose + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import info.appdev.charting.animation.Easing +import info.appdev.charting.charts.RadarChart +import info.appdev.charting.components.Legend +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.data.Entry +import info.appdev.charting.data.RadarData +import info.appdev.charting.highlight.Highlight +import info.appdev.charting.listener.OnChartValueSelectedListener + +/** + * A Composable wrapper for [RadarChart]. + */ +@Composable +fun RadarChart( + data: RadarData?, + modifier: Modifier = Modifier, + state: RadarChartState = rememberRadarChartState(), + onValueSelected: ((Entry?, Highlight?) -> Unit)? = null, + description: String? = null, + legend: ((Legend) -> Unit)? = null, + xAxisConfig: ((XAxis) -> Unit)? = null, + yAxisConfig: ((YAxis) -> Unit)? = null, + backgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, + touchEnabled: Boolean = true, + rotationEnabled: Boolean = true, + webLineWidth: Float = 2.5f, + webLineWidthInner: Float = 1.5f, + webAlpha: Int = 150, + skipWebLineCount: Int = 0, + animationDuration: Int = 0, + animationEasing: Easing.EasingFunction? = null, +) { + val context = LocalContext.current + + val chart = remember { + RadarChart(context).apply { + this.description.isEnabled = false + } + } + + DisposableEffect(chart) { + onDispose { chart.clear() } + } + + AndroidView( + factory = { chart }, + modifier = modifier.fillMaxSize(), + update = { radarChart -> + radarChart.setData(data) + + radarChart.setBackgroundColor(backgroundColor.toArgb()) + radarChart.setTouchEnabled(touchEnabled) + radarChart.isRotationEnabled = rotationEnabled + radarChart.webLineWidth = webLineWidth + radarChart.webLineWidthInner = webLineWidthInner + radarChart.webAlpha = webAlpha + radarChart.skipWebLineCount = skipWebLineCount + + radarChart.description.let { desc -> + if (description != null) { + desc.isEnabled = true + desc.text = description + } else { + desc.isEnabled = false + } + } + + legend?.invoke(radarChart.legend) + xAxisConfig?.invoke(radarChart.xAxis) + yAxisConfig?.invoke(radarChart.yAxis) + + if (onValueSelected != null) { + radarChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(entry: Entry, highlight: Highlight) = onValueSelected(entry, highlight) + override fun onNothingSelected() = onValueSelected(null, null) + }) + } + + if (animationDuration > 0) { + if (animationEasing != null) { + radarChart.animateXY(animationDuration, animationDuration, animationEasing, animationEasing) + } else { + radarChart.animateXY(animationDuration, animationDuration) + } + } + + radarChart.invalidate() + } + ) +} + diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ScatterChartComposable.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ScatterChartComposable.kt new file mode 100644 index 000000000..753b64749 --- /dev/null +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/ScatterChartComposable.kt @@ -0,0 +1,112 @@ +package info.appdev.charting.compose + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import info.appdev.charting.animation.Easing +import info.appdev.charting.charts.ScatterChart +import info.appdev.charting.components.Legend +import info.appdev.charting.components.XAxis +import info.appdev.charting.components.YAxis +import info.appdev.charting.data.Entry +import info.appdev.charting.data.ScatterData +import info.appdev.charting.highlight.Highlight +import info.appdev.charting.listener.OnChartValueSelectedListener + +/** + * A Composable wrapper for [ScatterChart]. + */ +@Composable +fun ScatterChart( + data: ScatterData?, + modifier: Modifier = Modifier, + state: ScatterChartState = rememberScatterChartState(), + onValueSelected: ((Entry?, Highlight?) -> Unit)? = null, + description: String? = null, + legend: ((Legend) -> Unit)? = null, + xAxisConfig: ((XAxis) -> Unit)? = null, + leftAxisConfig: ((YAxis) -> Unit)? = null, + rightAxisConfig: ((YAxis) -> Unit)? = null, + backgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, + gridBackgroundColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.LightGray, + drawGridBackground: Boolean = false, + touchEnabled: Boolean = true, + dragEnabled: Boolean = true, + scaleEnabled: Boolean = true, + scaleXEnabled: Boolean = true, + scaleYEnabled: Boolean = true, + pinchZoomEnabled: Boolean = true, + doubleTapToZoomEnabled: Boolean = true, + highlightPerTapEnabled: Boolean = true, + animationDuration: Int = 0, + animationEasing: Easing.EasingFunction? = null, +) { + val context = LocalContext.current + + val chart = remember { + ScatterChart(context).apply { + this.description.isEnabled = false + } + } + + DisposableEffect(chart) { + onDispose { chart.clear() } + } + + AndroidView( + factory = { chart }, + modifier = modifier.fillMaxSize(), + update = { scatterChart -> + scatterChart.setData(data) + + scatterChart.setBackgroundColor(backgroundColor.toArgb()) + scatterChart.setDrawGridBackground(drawGridBackground) + scatterChart.setGridBackgroundColor(gridBackgroundColor.toArgb()) + scatterChart.setTouchEnabled(touchEnabled) + scatterChart.isDragEnabled = dragEnabled + scatterChart.setScaleEnabled(scaleEnabled) + scatterChart.isScaleXEnabled = scaleXEnabled + scatterChart.isScaleYEnabled = scaleYEnabled + scatterChart.setPinchZoom(pinchZoomEnabled) + scatterChart.isDoubleTapToZoomEnabled = doubleTapToZoomEnabled + scatterChart.isHighlightPerTapEnabled = highlightPerTapEnabled + + scatterChart.description.let { desc -> + if (description != null) { + desc.isEnabled = true + desc.text = description + } else { + desc.isEnabled = false + } + } + + legend?.invoke(scatterChart.legend) + xAxisConfig?.invoke(scatterChart.xAxis) + leftAxisConfig?.invoke(scatterChart.axisLeft) + rightAxisConfig?.invoke(scatterChart.axisRight) + + if (onValueSelected != null) { + scatterChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(entry: Entry, highlight: Highlight) = onValueSelected(entry, highlight) + override fun onNothingSelected() = onValueSelected(null, null) + }) + } + + if (animationDuration > 0) { + if (animationEasing != null) { + scatterChart.animateXY(animationDuration, animationDuration, animationEasing, animationEasing) + } else { + scatterChart.animateXY(animationDuration, animationDuration) + } + } + + scatterChart.invalidate() + } + ) +} + diff --git a/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/examples/ChartExamples.kt b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/examples/ChartExamples.kt new file mode 100644 index 000000000..cb76969f2 --- /dev/null +++ b/chartLibCompose/src/main/kotlin/info/appdev/charting/compose/examples/ChartExamples.kt @@ -0,0 +1,345 @@ +package info.appdev.charting.compose.examples + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import info.appdev.charting.charts.ScatterChart +import info.appdev.charting.components.Legend +import info.appdev.charting.components.XAxis +import info.appdev.charting.compose.* +import info.appdev.charting.data.* + +/** + * Example composable demonstrating all chart types in Compose. + * This file serves as a reference for using the new Compose chart APIs. + */ +@Composable +fun ChartExamplesScreen() { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text("Chart Examples", style = MaterialTheme.typography.headlineMedium) + + LineChartExample() + BarChartExample() + PieChartExample() + ScatterChartExample() + RadarChartExample() + } +} + +@Composable +fun LineChartExample() { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Line Chart", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + + val entries = remember { + (0..10).map { Entry(it.toFloat(), (10..50).random().toFloat()) } + } + + val dataSet = remember(entries) { + LineDataSet(entries.toMutableList(), "Sample Data").apply { + color = android.graphics.Color.BLUE + lineWidth = 2.5f + isDrawCirclesEnabled = true + isDrawValues = false + isDrawFilledEnabled = true + fillColor = android.graphics.Color.BLUE + fillAlpha = 50 + } + } + + val lineData = remember(dataSet) { LineData(dataSet) } + + var selectedValue by remember { mutableStateOf(null) } + + LineChart( + data = lineData, + modifier = Modifier + .fillMaxWidth() + .height(250.dp), + description = "Monthly Sales", + animationDuration = 1000, + dragEnabled = true, + scaleEnabled = true, + pinchZoomEnabled = true, + onValueSelected = { entry, _ -> + selectedValue = entry?.y + }, + xAxisConfig = { xAxis -> + xAxis.position = XAxis.XAxisPosition.BOTTOM + xAxis.setDrawGridLines(true) + }, + leftAxisConfig = { axis -> + axis.axisMinimum = 0f + }, + rightAxisConfig = { axis -> + axis.isEnabled = false + }, + legend = { legend -> + legend.isEnabled = true + legend.textSize = 12f + } + ) + + selectedValue?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text("Selected Value: ${"%.2f".format(it)}", + style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@Composable +fun BarChartExample() { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Bar Chart", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + + val entries = remember { + (0..5).map { BarEntry(it.toFloat(), (20..80).random().toFloat()) } + } + + val dataSet = remember(entries) { + BarDataSet(entries.toMutableList(), "Revenue").apply { + color = android.graphics.Color.rgb(104, 241, 175) + isDrawValues = true + valueTextSize = 12f + } + } + + val barData = remember(dataSet) { BarData(dataSet) } + + BarChart( + data = barData, + modifier = Modifier + .fillMaxWidth() + .height(250.dp), + description = "Quarterly Revenue", + backgroundColor = Color(0xFFF5F5F5), + drawValueAboveBar = true, + animationDuration = 1200, + xAxisConfig = { xAxis -> + xAxis.position = XAxis.XAxisPosition.BOTTOM + xAxis.setDrawGridLines(false) + xAxis.granularity = 1f + }, + leftAxisConfig = { axis -> + axis.axisMinimum = 0f + } + ) + } + } +} + +@Composable +fun PieChartExample() { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Pie Chart", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + + val entries = remember { + listOf( + PieEntry(30f, "Product A"), + PieEntry(25f, "Product B"), + PieEntry(20f, "Product C"), + PieEntry(15f, "Product D"), + PieEntry(10f, "Product E") + ) + } + + val dataSet = remember(entries) { + PieDataSet(entries.toMutableList(), "Sales Distribution").apply { + colors = mutableListOf( + android.graphics.Color.rgb(255, 102, 0), + android.graphics.Color.rgb(76, 175, 80), + android.graphics.Color.rgb(33, 150, 243), + android.graphics.Color.rgb(156, 39, 176), + android.graphics.Color.rgb(255, 193, 7) + ) + valueTextSize = 14f +// valueTextColor = android.graphics.Color.WHITE + } + } + + val pieData = remember(dataSet) { PieData(dataSet) } + + PieChart( + data = pieData, + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + drawHoleEnabled = true, + holeRadius = 40f, + transparentCircleRadius = 45f, + centerText = "Market Share", + rotationEnabled = true, + usePercentValuesEnabled = true, + drawEntryLabelsEnabled = true, + animationDuration = 1500, + legend = { legend -> + legend.verticalAlignment = Legend.LegendVerticalAlignment.BOTTOM + legend.horizontalAlignment = Legend.LegendHorizontalAlignment.CENTER + legend.orientation = Legend.LegendOrientation.HORIZONTAL + legend.setDrawInside(false) + } + ) + } + } +} + +@Composable +fun ScatterChartExample() { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Scatter Chart", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + + val entries = remember { + (0..20).map { + Entry(it.toFloat(), (10..50).random().toFloat()) + } + } + + val dataSet = remember(entries) { + ScatterDataSet(entries.toMutableList(), "Data Points").apply { + color = android.graphics.Color.rgb(255, 87, 34) + setScatterShape(ScatterChart.ScatterShape.CIRCLE) + scatterShapeSize = 12f + isDrawValues = false + } + } + + val scatterData = remember(dataSet) { ScatterData(dataSet) } + + ScatterChart( + data = scatterData, + modifier = Modifier + .fillMaxWidth() + .height(250.dp), + description = "Distribution Analysis", + animationDuration = 1000, + xAxisConfig = { xAxis -> + xAxis.position = XAxis.XAxisPosition.BOTTOM + } + ) + } + } +} + +@Composable +fun RadarChartExample() { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Radar Chart", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + + val entries = remember { + listOf( + RadarEntry(8f, "Speed"), + RadarEntry(7f, "Strength"), + RadarEntry(6f, "Defense"), + RadarEntry(9f, "Agility"), + RadarEntry(7f, "Intelligence") + ) + } + + val dataSet = remember(entries) { + RadarDataSet(entries.toMutableList(), "Character Stats").apply { + color = android.graphics.Color.rgb(103, 110, 129) + fillColor = android.graphics.Color.rgb(103, 110, 129) + isDrawFilledEnabled = true + fillAlpha = 100 + lineWidth = 2f + isDrawValues = true + } + } + + val radarData = remember(dataSet) { RadarData(dataSet) } + + RadarChart( + data = radarData, + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + description = "Performance Metrics", + rotationEnabled = true, + webLineWidth = 1.5f, + webLineWidthInner = 0.75f, + webAlpha = 100, + animationDuration = 1000, + xAxisConfig = { xAxis -> + xAxis.textSize = 12f + } + ) + } + } +} + +/** + * Example showing state management with dynamic updates + */ +@Composable +fun DynamicChartExample() { + val state = rememberLineChartState() + var dataPoints by remember { mutableStateOf(10) } + + Column(modifier = Modifier.padding(16.dp)) { + Text("Dynamic Chart", style = MaterialTheme.typography.titleMedium) + + LineChart( + data = state.data, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + state = state + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button(onClick = { + if (dataPoints > 5) dataPoints-- + }) { + Text("- Points") + } + + Text("Points: $dataPoints") + + Button(onClick = { + if (dataPoints < 20) dataPoints++ + }) { + Text("+ Points") + } + } + + Button( + onClick = { + val entries = (0 until dataPoints).map { + Entry(it.toFloat(), (10..50).random().toFloat()) + } + val dataSet = LineDataSet(entries.toMutableList(), "Random Data") + state.data = LineData(dataSet) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Refresh Data") + } + } +} + diff --git a/settings.gradle.kts b/settings.gradle.kts index 186ce51d7..2edcf4e5b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,3 @@ include("chartLib") +include("chartLibCompose") include("app")