From 3a4e82ccc2fd5e8c10b60e0f104b374604ef2a00 Mon Sep 17 00:00:00 2001 From: Kelly Date: Thu, 8 Sep 2022 17:27:19 +0800 Subject: [PATCH] Add a Chart Widget for Settings for in SPA. The chart widget supports line chart, pie chart and bar chart. Test: unit test and manually test on device Bug: 244401879 Change-Id: I7adf48c71452dd0a995d548b7ef6e03e02a8b665 --- packages/SettingsLib/Spa/gallery/build.gradle | 1 + .../spa/gallery/GallerySpaEnvironment.kt | 2 + .../settingslib/spa/gallery/home/HomePage.kt | 2 + .../settingslib/spa/gallery/page/ChartPage.kt | 162 +++++++++++++ packages/SettingsLib/Spa/settings.gradle | 1 + packages/SettingsLib/Spa/spa/Android.bp | 1 + packages/SettingsLib/Spa/spa/build.gradle | 1 + .../settingslib/spa/widget/chart/BarChart.kt | 202 ++++++++++++++++ .../spa/widget/chart/ColorPalette.kt | 29 +++ .../settingslib/spa/widget/chart/LineChart.kt | 217 ++++++++++++++++++ .../settingslib/spa/widget/chart/PieChart.kt | 163 +++++++++++++ .../settingslib/spa/widget/ChartTest.kt | 104 +++++++++ 12 files changed, 885 insertions(+) create mode 100644 packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/page/ChartPage.kt create mode 100644 packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/BarChart.kt create mode 100644 packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/ColorPalette.kt create mode 100644 packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/LineChart.kt create mode 100644 packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/PieChart.kt create mode 100644 packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ChartTest.kt diff --git a/packages/SettingsLib/Spa/gallery/build.gradle b/packages/SettingsLib/Spa/gallery/build.gradle index 20dd707de4821..f0b6ac540c493 100644 --- a/packages/SettingsLib/Spa/gallery/build.gradle +++ b/packages/SettingsLib/Spa/gallery/build.gradle @@ -58,4 +58,5 @@ android { dependencies { implementation(project(":spa")) + implementation "com.github.PhilJay:MPAndroidChart:v3.1.0-alpha" } diff --git a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/GallerySpaEnvironment.kt b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/GallerySpaEnvironment.kt index 92f4fe49cd021..742e2712e41a2 100644 --- a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/GallerySpaEnvironment.kt +++ b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/GallerySpaEnvironment.kt @@ -24,6 +24,7 @@ import com.android.settingslib.spa.framework.common.createSettingsPage import com.android.settingslib.spa.gallery.button.ActionButtonPageProvider import com.android.settingslib.spa.gallery.home.HomePageProvider import com.android.settingslib.spa.gallery.page.ArgumentPageProvider +import com.android.settingslib.spa.gallery.page.ChartPageProvider import com.android.settingslib.spa.gallery.page.FooterPageProvider import com.android.settingslib.spa.gallery.page.IllustrationPageProvider import com.android.settingslib.spa.gallery.page.ProgressBarPageProvider @@ -69,6 +70,7 @@ class GallerySpaEnvironment(context: Context) : SpaEnvironment(context) { CategoryPageProvider, ActionButtonPageProvider, ProgressBarPageProvider, + ChartPageProvider, ), rootPages = listOf( HomePageProvider.createSettingsPage(), diff --git a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/home/HomePage.kt b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/home/HomePage.kt index 04d0fe03e1e88..83c72c75e9f3b 100644 --- a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/home/HomePage.kt +++ b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/home/HomePage.kt @@ -29,6 +29,7 @@ import com.android.settingslib.spa.gallery.SettingsPageProviderEnum import com.android.settingslib.spa.gallery.button.ActionButtonPageProvider import com.android.settingslib.spa.gallery.page.ArgumentPageModel import com.android.settingslib.spa.gallery.page.ArgumentPageProvider +import com.android.settingslib.spa.gallery.page.ChartPageProvider import com.android.settingslib.spa.gallery.page.FooterPageProvider import com.android.settingslib.spa.gallery.page.IllustrationPageProvider import com.android.settingslib.spa.gallery.page.ProgressBarPageProvider @@ -56,6 +57,7 @@ object HomePageProvider : SettingsPageProvider { CategoryPageProvider.buildInjectEntry().setLink(fromPage = owner).build(), ActionButtonPageProvider.buildInjectEntry().setLink(fromPage = owner).build(), ProgressBarPageProvider.buildInjectEntry().setLink(fromPage = owner).build(), + ChartPageProvider.buildInjectEntry().setLink(fromPage = owner).build(), ) } diff --git a/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/page/ChartPage.kt b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/page/ChartPage.kt new file mode 100644 index 0000000000000..160e77b061ebf --- /dev/null +++ b/packages/SettingsLib/Spa/gallery/src/com/android/settingslib/spa/gallery/page/ChartPage.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settingslib.spa.gallery.page + +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.settingslib.spa.framework.common.SettingsEntry +import com.android.settingslib.spa.framework.common.SettingsEntryBuilder +import com.android.settingslib.spa.framework.common.SettingsPage +import com.android.settingslib.spa.framework.common.SettingsPageProvider +import com.android.settingslib.spa.framework.compose.navigator +import com.android.settingslib.spa.framework.theme.SettingsTheme +import com.android.settingslib.spa.widget.chart.BarChart +import com.android.settingslib.spa.widget.chart.BarChartData +import com.android.settingslib.spa.widget.chart.BarChartModel +import com.android.settingslib.spa.widget.chart.LineChart +import com.android.settingslib.spa.widget.chart.LineChartData +import com.android.settingslib.spa.widget.chart.LineChartModel +import com.android.settingslib.spa.widget.chart.PieChart +import com.android.settingslib.spa.widget.chart.PieChartData +import com.android.settingslib.spa.widget.chart.PieChartModel +import com.android.settingslib.spa.widget.preference.Preference +import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spa.widget.scaffold.RegularScaffold +import com.github.mikephil.charting.formatter.IAxisValueFormatter +import java.text.NumberFormat + +private enum class WeekDay(val num: Int) { + Sun(0), Mon(1), Tue(2), Wed(3), Thu(4), Fri(5), Sat(6), +} +private const val TITLE = "Sample Chart" + +object ChartPageProvider : SettingsPageProvider { + override val name = "Chart" + + override fun buildEntry(arguments: Bundle?): List { + val owner = SettingsPage.create(name) + val entryList = mutableListOf() + entryList.add( + SettingsEntryBuilder.create("Line Chart", owner) + .setUiLayoutFn { + Preference(object : PreferenceModel { + override val title = "Line Chart" + }) + LineChart( + lineChartModel = object : LineChartModel { + override val chartDataList = listOf( + LineChartData(x = 0f, y = 0f), + LineChartData(x = 1f, y = 0.1f), + LineChartData(x = 2f, y = 0.2f), + LineChartData(x = 3f, y = 0.6f), + LineChartData(x = 4f, y = 0.9f), + LineChartData(x = 5f, y = 1.0f), + LineChartData(x = 6f, y = 0.8f), + ) + override val xValueFormatter = + IAxisValueFormatter { value, _ -> + "${WeekDay.values()[value.toInt()]}" + } + override val yValueFormatter = + IAxisValueFormatter { value, _ -> + NumberFormat.getPercentInstance().format(value) + } + } + ) + }.build() + ) + entryList.add( + SettingsEntryBuilder.create("Bar Chart", owner) + .setUiLayoutFn { + Preference(object : PreferenceModel { + override val title = "Bar Chart" + }) + BarChart( + barChartModel = object : BarChartModel { + override val chartDataList = listOf( + BarChartData(x = 0f, y = 12f), + BarChartData(x = 1f, y = 5f), + BarChartData(x = 2f, y = 21f), + BarChartData(x = 3f, y = 5f), + BarChartData(x = 4f, y = 10f), + BarChartData(x = 5f, y = 9f), + BarChartData(x = 6f, y = 1f), + ) + override val xValueFormatter = + IAxisValueFormatter { value, _ -> + "${WeekDay.values()[value.toInt()]}" + } + override val yValueFormatter = + IAxisValueFormatter { value, _ -> + "${value.toInt()}m" + } + override val yAxisMaxValue = 30f + } + ) + }.build() + ) + entryList.add( + SettingsEntryBuilder.create("Pie Chart", owner) + .setUiLayoutFn { + Preference(object : PreferenceModel { + override val title = "Pie Chart" + }) + PieChart( + pieChartModel = object : PieChartModel { + override val chartDataList = listOf( + PieChartData(label = "Settings", value = 20f), + PieChartData(label = "Chrome", value = 5f), + PieChartData(label = "Gmail", value = 3f), + ) + override val centerText = "Today" + } + ) + }.build() + ) + + return entryList + } + + fun buildInjectEntry(): SettingsEntryBuilder { + return SettingsEntryBuilder.createInject(owner = SettingsPage.create(name)) + .setIsAllowSearch(true) + .setUiLayoutFn { + Preference(object : PreferenceModel { + override val title = TITLE + override val onClick = navigator(name) + }) + } + } + + @Composable + override fun Page(arguments: Bundle?) { + RegularScaffold(title = TITLE) { + for (entry in buildEntry(arguments)) { + entry.UiLayout(arguments) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ChartPagePreview() { + SettingsTheme { + ChartPageProvider.Page(null) + } +} diff --git a/packages/SettingsLib/Spa/settings.gradle b/packages/SettingsLib/Spa/settings.gradle index 897fa675888d0..cef79c16b38a3 100644 --- a/packages/SettingsLib/Spa/settings.gradle +++ b/packages/SettingsLib/Spa/settings.gradle @@ -26,6 +26,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url "https://jitpack.io"} } } rootProject.name = "SpaLib" diff --git a/packages/SettingsLib/Spa/spa/Android.bp b/packages/SettingsLib/Spa/spa/Android.bp index 8b29366ef6a30..037b45e6732ac 100644 --- a/packages/SettingsLib/Spa/spa/Android.bp +++ b/packages/SettingsLib/Spa/spa/Android.bp @@ -33,6 +33,7 @@ android_library { "androidx.navigation_navigation-compose", "com.google.android.material_material", "lottie_compose", + "MPAndroidChart", ], kotlincflags: [ "-Xjvm-default=all", diff --git a/packages/SettingsLib/Spa/spa/build.gradle b/packages/SettingsLib/Spa/spa/build.gradle index 3b159e95cc55b..682d3fc055ccc 100644 --- a/packages/SettingsLib/Spa/spa/build.gradle +++ b/packages/SettingsLib/Spa/spa/build.gradle @@ -64,4 +64,5 @@ dependencies { api "com.google.android.material:material:1.7.0-alpha03" debugApi "androidx.compose.ui:ui-tooling:$jetpack_compose_version" implementation "com.airbnb.android:lottie-compose:5.2.0" + implementation "com.github.PhilJay:MPAndroidChart:v3.1.0-alpha" } diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/BarChart.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/BarChart.kt new file mode 100644 index 0000000000000..0b0f07e670ee3 --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/BarChart.kt @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settingslib.spa.widget.chart + +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.android.settingslib.spa.framework.theme.divider +import com.github.mikephil.charting.charts.BarChart +import com.github.mikephil.charting.components.XAxis +import com.github.mikephil.charting.data.BarData +import com.github.mikephil.charting.data.BarDataSet +import com.github.mikephil.charting.data.BarEntry +import com.github.mikephil.charting.formatter.IAxisValueFormatter + +/** + * The chart settings model for [BarChart]. + */ +interface BarChartModel { + /** + * The chart data list for [BarChart]. + */ + val chartDataList: List + + /** + * The label text formatter for x value. + */ + val xValueFormatter: IAxisValueFormatter? + get() = null + + /** + * The label text formatter for y value. + */ + val yValueFormatter: IAxisValueFormatter? + get() = null + + /** + * The minimum value for y-axis. + */ + val yAxisMinValue: Float + get() = 0f + + /** + * The maximum value for y-axis. + */ + val yAxisMaxValue: Float + get() = 1f + + /** + * The label count for y-axis. + */ + val yAxisLabelCount: Int + get() = 3 +} + +data class BarChartData( + var x: Float?, + var y: Float?, +) + +@Composable +fun BarChart(barChartModel: BarChartModel) { + BarChart( + chartDataList = barChartModel.chartDataList, + xValueFormatter = barChartModel.xValueFormatter, + yValueFormatter = barChartModel.yValueFormatter, + yAxisMinValue = barChartModel.yAxisMinValue, + yAxisMaxValue = barChartModel.yAxisMaxValue, + yAxisLabelCount = barChartModel.yAxisLabelCount, + ) +} + +@Composable +fun BarChart( + chartDataList: List, + modifier: Modifier = Modifier, + xValueFormatter: IAxisValueFormatter? = null, + yValueFormatter: IAxisValueFormatter? = null, + yAxisMinValue: Float = 0f, + yAxisMaxValue: Float = 30f, + yAxisLabelCount: Int = 4, +) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Column( + modifier = Modifier + .padding(16.dp) + .height(170.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + val colorScheme = MaterialTheme.colorScheme + val labelTextColor = colorScheme.onSurfaceVariant.toArgb() + val labelTextSize = MaterialTheme.typography.bodyMedium.fontSize.value + Crossfade(targetState = chartDataList) { barChartData -> + AndroidView(factory = { context -> + BarChart(context).apply { + // Fixed Settings. + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + this.description.isEnabled = false + this.legend.isEnabled = false + this.extraBottomOffset = 4f + this.setScaleEnabled(false) + + this.xAxis.position = XAxis.XAxisPosition.BOTTOM + this.xAxis.setDrawGridLines(false) + this.xAxis.setDrawAxisLine(false) + this.xAxis.textColor = labelTextColor + this.xAxis.textSize = labelTextSize + this.xAxis.yOffset = 10f + + this.axisLeft.isEnabled = false + this.axisRight.setDrawAxisLine(false) + this.axisRight.textSize = labelTextSize + this.axisRight.textColor = labelTextColor + this.axisRight.gridColor = colorScheme.divider.toArgb() + this.axisRight.xOffset = 10f + + // Customizable Settings. + this.xAxis.valueFormatter = xValueFormatter + this.axisRight.valueFormatter = yValueFormatter + + this.axisLeft.axisMinimum = yAxisMinValue + this.axisLeft.axisMaximum = yAxisMaxValue + this.axisRight.axisMinimum = yAxisMinValue + this.axisRight.axisMaximum = yAxisMaxValue + + this.axisRight.setLabelCount(yAxisLabelCount, true) + } + }, + modifier = Modifier + .wrapContentSize() + .padding(4.dp), + update = { + updateBarChartWithData(it, barChartData, colorScheme) + } + ) + } + } + } +} + +fun updateBarChartWithData( + chart: BarChart, + data: List, + colorScheme: ColorScheme +) { + val entries = ArrayList() + for (i in data.indices) { + val item = data[i] + entries.add(BarEntry(item.x ?: 0.toFloat(), item.y ?: 0.toFloat())) + } + + val ds = BarDataSet(entries, "") + ds.colors = arrayListOf(colorScheme.surfaceVariant.toArgb()) + ds.setDrawValues(false) + ds.isHighlightEnabled = true + ds.highLightColor = colorScheme.primary.toArgb() + ds.highLightAlpha = 255 + // TODO: Sets round corners for bars. + + val d = BarData(ds) + chart.data = d + chart.invalidate() +} diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/ColorPalette.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/ColorPalette.kt new file mode 100644 index 0000000000000..70bc017c6c5b5 --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/ColorPalette.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settingslib.spa.widget.chart + +import androidx.compose.ui.graphics.Color + +object ColorPalette { + // Alpha = 1 + val red: Color = Color(0xffd93025) + val orange: Color = Color(0xffe8710a) + val yellow: Color = Color(0xfff9ab00) + val green: Color = Color(0xff1e8e3e) + val cyan: Color = Color(0xff12b5cb) + val blue: Color = Color(0xff1a73e8) +} diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/LineChart.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/LineChart.kt new file mode 100644 index 0000000000000..7d482656d87c1 --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/LineChart.kt @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settingslib.spa.widget.chart + +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.android.settingslib.spa.framework.theme.divider +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.XAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.IAxisValueFormatter + +/** + * The chart settings model for [LineChart]. + */ +interface LineChartModel { + /** + * The chart data list for [LineChart]. + */ + val chartDataList: List + + /** + * The label text formatter for x value. + */ + val xValueFormatter: IAxisValueFormatter? + get() = null + + /** + * The label text formatter for y value. + */ + val yValueFormatter: IAxisValueFormatter? + get() = null + + /** + * The minimum value for y-axis. + */ + val yAxisMinValue: Float + get() = 0f + + /** + * The maximum value for y-axis. + */ + val yAxisMaxValue: Float + get() = 1f + + /** + * The label count for y-axis. + */ + val yAxisLabelCount: Int + get() = 3 + + /** + * Indicates whether to smooth the line. + */ + val showSmoothLine: Boolean + get() = true +} + +data class LineChartData( + var x: Float?, + var y: Float?, +) + +@Composable +fun LineChart(lineChartModel: LineChartModel) { + LineChart( + chartDataList = lineChartModel.chartDataList, + xValueFormatter = lineChartModel.xValueFormatter, + yValueFormatter = lineChartModel.yValueFormatter, + yAxisMinValue = lineChartModel.yAxisMinValue, + yAxisMaxValue = lineChartModel.yAxisMaxValue, + yAxisLabelCount = lineChartModel.yAxisLabelCount, + showSmoothLine = lineChartModel.showSmoothLine, + ) +} + +@Composable +fun LineChart( + chartDataList: List, + modifier: Modifier = Modifier, + xValueFormatter: IAxisValueFormatter? = null, + yValueFormatter: IAxisValueFormatter? = null, + yAxisMinValue: Float = 0f, + yAxisMaxValue: Float = 1f, + yAxisLabelCount: Int = 3, + showSmoothLine: Boolean = true, +) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Column( + modifier = Modifier + .padding(16.dp) + .height(170.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + val colorScheme = MaterialTheme.colorScheme + val labelTextColor = colorScheme.onSurfaceVariant.toArgb() + val labelTextSize = MaterialTheme.typography.bodyMedium.fontSize.value + Crossfade(targetState = chartDataList) { lineChartData -> + AndroidView(factory = { context -> + LineChart(context).apply { + // Fixed Settings. + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + this.description.isEnabled = false + this.legend.isEnabled = false + this.extraBottomOffset = 4f + this.setTouchEnabled(false) + + this.xAxis.position = XAxis.XAxisPosition.BOTTOM + this.xAxis.setDrawGridLines(false) + this.xAxis.setDrawAxisLine(false) + this.xAxis.textColor = labelTextColor + this.xAxis.textSize = labelTextSize + this.xAxis.yOffset = 10f + + this.axisLeft.isEnabled = false + + this.axisRight.setDrawAxisLine(false) + this.axisRight.textSize = labelTextSize + this.axisRight.textColor = labelTextColor + this.axisRight.gridColor = colorScheme.divider.toArgb() + this.axisRight.xOffset = 10f + this.axisRight.isGranularityEnabled = true + + // Customizable Settings. + this.xAxis.valueFormatter = xValueFormatter + this.axisRight.valueFormatter = yValueFormatter + + this.axisLeft.axisMinimum = + yAxisMinValue - 0.01f * (yAxisMaxValue - yAxisMinValue) + this.axisRight.axisMinimum = + yAxisMinValue - 0.01f * (yAxisMaxValue - yAxisMinValue) + this.axisRight.granularity = + (yAxisMaxValue - yAxisMinValue) / (yAxisLabelCount - 1) + } + }, + modifier = Modifier + .wrapContentSize() + .padding(4.dp), + update = { + updateLineChartWithData(it, lineChartData, colorScheme, showSmoothLine) + }) + } + } + } +} + +fun updateLineChartWithData( + chart: LineChart, + data: List, + colorScheme: ColorScheme, + showSmoothLine: Boolean +) { + val entries = ArrayList() + for (i in data.indices) { + val item = data[i] + entries.add(Entry(item.x ?: 0.toFloat(), item.y ?: 0.toFloat())) + } + + val ds = LineDataSet(entries, "") + ds.colors = arrayListOf(colorScheme.primary.toArgb()) + ds.lineWidth = 2f + if (showSmoothLine) { + ds.mode = LineDataSet.Mode.CUBIC_BEZIER + } + ds.setDrawValues(false) + ds.setDrawCircles(false) + ds.setDrawFilled(true) + ds.fillColor = colorScheme.primary.toArgb() + ds.fillAlpha = 38 + // TODO: enable gradient fill color for line chart. + + val d = LineData(ds) + chart.data = d + chart.invalidate() +} diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/PieChart.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/PieChart.kt new file mode 100644 index 0000000000000..51a8d0d5ff2be --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/widget/chart/PieChart.kt @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settingslib.spa.widget.chart + +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.github.mikephil.charting.charts.PieChart +import com.github.mikephil.charting.data.PieData +import com.github.mikephil.charting.data.PieDataSet +import com.github.mikephil.charting.data.PieEntry + +/** + * The chart settings model for [PieChart]. + */ +interface PieChartModel { + /** + * The chart data list for [PieChart]. + */ + val chartDataList: List + + /** + * The center text in the hole of [PieChart]. + */ + val centerText: String? + get() = null +} + +val colorPalette = arrayListOf( + ColorPalette.blue.toArgb(), + ColorPalette.red.toArgb(), + ColorPalette.yellow.toArgb(), + ColorPalette.green.toArgb(), + ColorPalette.orange.toArgb(), + ColorPalette.cyan.toArgb(), + Color.Blue.toArgb() +) + +data class PieChartData( + var value: Float?, + var label: String?, +) + +@Composable +fun PieChart(pieChartModel: PieChartModel) { + PieChart( + chartDataList = pieChartModel.chartDataList, + centerText = pieChartModel.centerText, + ) +} + +@Composable +fun PieChart( + chartDataList: List, + modifier: Modifier = Modifier, + centerText: String? = null, +) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Column( + modifier = Modifier + .padding(16.dp) + .height(280.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + val colorScheme = MaterialTheme.colorScheme + val labelTextColor = colorScheme.onSurfaceVariant.toArgb() + val labelTextSize = MaterialTheme.typography.bodyMedium.fontSize.value + val centerTextSize = MaterialTheme.typography.titleLarge.fontSize.value + Crossfade(targetState = chartDataList) { pieChartData -> + AndroidView(factory = { context -> + PieChart(context).apply { + // Fixed settings.` + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + this.isRotationEnabled = false + this.description.isEnabled = false + this.legend.isEnabled = false + this.setTouchEnabled(false) + + this.isDrawHoleEnabled = true + this.holeRadius = 90.0f + this.setHoleColor(Color.Transparent.toArgb()) + this.setEntryLabelColor(labelTextColor) + this.setEntryLabelTextSize(labelTextSize) + this.setCenterTextSize(centerTextSize) + this.setCenterTextColor(colorScheme.onSurface.toArgb()) + + // Customizable settings. + this.centerText = centerText + } + }, + modifier = Modifier + .wrapContentSize() + .padding(4.dp), update = { + updatePieChartWithData(it, pieChartData) + }) + } + } + } +} + +fun updatePieChartWithData( + chart: PieChart, + data: List, +) { + val entries = ArrayList() + for (i in data.indices) { + val item = data[i] + entries.add(PieEntry(item.value ?: 0.toFloat(), item.label ?: "")) + } + + val ds = PieDataSet(entries, "") + ds.setDrawValues(false) + ds.colors = colorPalette + ds.sliceSpace = 2f + ds.yValuePosition = PieDataSet.ValuePosition.OUTSIDE_SLICE + ds.xValuePosition = PieDataSet.ValuePosition.OUTSIDE_SLICE + ds.valueLineColor = Color.Transparent.toArgb() + ds.valueLinePart1Length = 0.1f + ds.valueLinePart2Length = 0f + + val d = PieData(ds) + chart.data = d + chart.invalidate() +} diff --git a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ChartTest.kt b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ChartTest.kt new file mode 100644 index 0000000000000..fa7a98add2ae3 --- /dev/null +++ b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/ChartTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settingslib.spa.widget + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settingslib.spa.widget.chart.BarChart +import com.android.settingslib.spa.widget.chart.BarChartData +import com.android.settingslib.spa.widget.chart.LineChart +import com.android.settingslib.spa.widget.chart.LineChartData +import com.android.settingslib.spa.widget.chart.PieChart +import com.android.settingslib.spa.widget.chart.PieChartData +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChartTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val Chart = SemanticsPropertyKey("Chart") + private var SemanticsPropertyReceiver.chart by Chart + private fun hasChart(chart: String): SemanticsMatcher = + SemanticsMatcher.expectValue(Chart, chart) + + @Test + fun line_chart_displayed() { + composeTestRule.setContent { + LineChart( + chartDataList = listOf( + LineChartData(x = 0f, y = 0f), + LineChartData(x = 1f, y = 0.1f), + LineChartData(x = 2f, y = 0.2f), + LineChartData(x = 3f, y = 0.7f), + LineChartData(x = 4f, y = 0.9f), + LineChartData(x = 5f, y = 1.0f), + LineChartData(x = 6f, y = 0.8f), + ), + modifier = Modifier.semantics { chart = "line" } + ) + } + + composeTestRule.onNode(hasChart("line")).assertIsDisplayed() + } + + @Test + fun bar_chart_displayed() { + composeTestRule.setContent { + BarChart( + chartDataList = listOf( + BarChartData(x = 0f, y = 12f), + BarChartData(x = 1f, y = 5f), + BarChartData(x = 2f, y = 21f), + BarChartData(x = 3f, y = 5f), + BarChartData(x = 4f, y = 10f), + BarChartData(x = 5f, y = 9f), + BarChartData(x = 6f, y = 1f), + ), + yAxisMaxValue = 30f, + modifier = Modifier.semantics { chart = "bar" } + ) + } + + composeTestRule.onNode(hasChart("bar")).assertIsDisplayed() + } + + @Test + fun pie_chart_displayed() { + composeTestRule.setContent { + PieChart( + chartDataList = listOf( + PieChartData(label = "Settings", value = 20f), + PieChartData(label = "Chrome", value = 5f), + PieChartData(label = "Gmail", value = 3f), + ), + centerText = "Today", + modifier = Modifier.semantics { chart = "pie" } + ) + } + + composeTestRule.onNode(hasChart("pie")).assertIsDisplayed() + } +}