Skip to content

Commit d458018

Browse files
committed
feat: new chart component
1 parent fd5253a commit d458018

File tree

9 files changed

+285
-225
lines changed

9 files changed

+285
-225
lines changed

app/build.gradle.kts

+1-1
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,11 @@ dependencies {
9393
implementation(libs.bundles.compose)
9494
implementation(libs.bundles.kotlin)
9595
implementation(libs.bundles.ktor)
96+
implementation(libs.bundles.vico)
9697
implementation(libs.google.android.material)
9798
implementation(libs.google.dagger.hilt)
9899
implementation(libs.google.protobuf.javalite)
99100
implementation(libs.google.mlkit.barcodeScanning)
100-
implementation(libs.philJay.mpAndroidChart)
101101

102102
kapt(libs.google.dagger.hilt.compiler)
103103

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.tien.piholeconnect.model
2+
3+
import androidx.compose.ui.graphics.Color
4+
import com.patrykandpatrick.vico.core.entry.ChartEntry
5+
6+
class Entry(
7+
override val x: Float,
8+
override val y: Float,
9+
val xDisplayValue: String? = null,
10+
val yDisplayValue: String? = null,
11+
val xLabel: String? = null,
12+
val yLabel: String? = null,
13+
) : ChartEntry {
14+
override fun withY(y: Float): ChartEntry = Entry(x, y, xDisplayValue, yDisplayValue, xLabel, yLabel)
15+
}
16+
17+
data class LineChartData(
18+
val label: String,
19+
val data: Iterable<Coordinate>,
20+
val color: Color? = null,
21+
)
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
package com.tien.piholeconnect.model
22

3-
typealias Coordinate = Pair<Float, Float>
3+
typealias Coordinate = Pair<Number, Number>
Original file line numberDiff line numberDiff line change
@@ -1,131 +1,107 @@
11
package com.tien.piholeconnect.ui.component
22

3-
import android.graphics.Color
43
import androidx.compose.foundation.layout.fillMaxSize
5-
import androidx.compose.material3.LocalContentColor
4+
import androidx.compose.material3.MaterialTheme
65
import androidx.compose.runtime.Composable
6+
import androidx.compose.runtime.LaunchedEffect
77
import androidx.compose.runtime.remember
88
import androidx.compose.ui.Modifier
99
import androidx.compose.ui.tooling.preview.Preview
10-
import androidx.compose.ui.viewinterop.AndroidView
11-
import com.github.mikephil.charting.animation.Easing.EaseOutBounce
12-
import com.github.mikephil.charting.data.Entry
13-
import com.github.mikephil.charting.data.LineData
14-
import com.github.mikephil.charting.data.LineDataSet
15-
import com.github.mikephil.charting.data.LineDataSet.Mode.CUBIC_BEZIER
16-
import com.github.mikephil.charting.highlight.Highlight
17-
import com.github.mikephil.charting.listener.OnChartValueSelectedListener
18-
import com.tien.piholeconnect.model.Coordinate
19-
import com.tien.piholeconnect.ui.theme.toColorInt
20-
21-
data class LineChartData(
22-
val label: String,
23-
val data: Iterable<Coordinate>,
24-
val configure: (LineDataSet.() -> Unit) = {}
25-
)
26-
27-
data class SelectedValue(val label: String, val value: Coordinate?)
10+
import com.patrykandpatrick.vico.compose.axis.horizontal.bottomAxis
11+
import com.patrykandpatrick.vico.compose.axis.vertical.endAxis
12+
import com.patrykandpatrick.vico.compose.chart.Chart
13+
import com.patrykandpatrick.vico.compose.chart.line.lineChart
14+
import com.patrykandpatrick.vico.compose.chart.scroll.rememberChartScrollSpec
15+
import com.patrykandpatrick.vico.compose.m3.style.m3ChartStyle
16+
import com.patrykandpatrick.vico.compose.style.ProvideChartStyle
17+
import com.patrykandpatrick.vico.core.axis.horizontal.HorizontalAxis
18+
import com.patrykandpatrick.vico.core.axis.vertical.VerticalAxis
19+
import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer
20+
import com.tien.piholeconnect.model.Entry
21+
import com.tien.piholeconnect.model.LineChartData
22+
import com.tien.piholeconnect.ui.theme.PiHoleConnectTheme
23+
import com.tien.piholeconnect.ui.theme.success
24+
import java.text.DateFormat
25+
import java.text.DecimalFormat
2826

2927
@Composable
3028
fun LineChart(
3129
modifier: Modifier = Modifier,
32-
lineData: LineChartData,
33-
onValueSelected: (Iterable<SelectedValue>) -> Unit = {},
34-
configure: com.github.mikephil.charting.charts.LineChart.() -> Unit = {}
35-
) = LineChart(modifier, listOf(lineData), onValueSelected, configure)
30+
data: LineChartData,
31+
xAxisFormatter: ((y: Number) -> String)? = null
32+
) = LineChart(modifier, listOf(data), xAxisFormatter)
3633

3734
@Composable
3835
fun LineChart(
3936
modifier: Modifier = Modifier,
40-
lineData: Iterable<LineChartData>,
41-
onValueSelected: (Iterable<SelectedValue>) -> Unit = {},
42-
configure: com.github.mikephil.charting.charts.LineChart.() -> Unit = {}
37+
data: Iterable<LineChartData>,
38+
xAxisFormatter: ((y: Number) -> String)? = null
39+
) = ProvideChartStyle(
40+
m3ChartStyle(entityColors = data.map { it.color ?: MaterialTheme.colorScheme.primary })
4341
) {
44-
val contentColor = LocalContentColor.current.toColorInt()
45-
46-
val parsedData = remember(lineData) {
47-
LineData(lineData.map {
48-
val lineDataSet = LineDataSet(
49-
it.data.map { pair -> Entry(pair.first, pair.second) },
50-
it.label
51-
)
52-
53-
lineDataSet.configure(contentColor)
54-
it.configure(lineDataSet)
55-
56-
lineDataSet
57-
})
58-
}
59-
60-
val listener = remember(lineData) {
61-
object : OnChartValueSelectedListener {
62-
override fun onValueSelected(e: Entry, h: Highlight) {
63-
onValueSelected(lineData.map {
64-
SelectedValue(
65-
it.label,
66-
it.data.firstOrNull { value -> value.first == e.x })
67-
})
68-
}
69-
70-
override fun onNothingSelected() {
71-
onValueSelected(lineData.map { SelectedValue(it.label, null) })
42+
val entries = remember(data) {
43+
data.map { lineData ->
44+
lineData.data.mapIndexed { index, coordinate ->
45+
Entry(
46+
if (xAxisFormatter == null) coordinate.first.toFloat() else index.toFloat(),
47+
coordinate.second.toFloat(),
48+
xDisplayValue = xAxisFormatter?.invoke(coordinate.first),
49+
yLabel = lineData.label
50+
)
7251
}
7352
}
7453
}
7554

76-
AndroidView(
77-
factory = {
78-
val chart = com.github.mikephil.charting.charts.LineChart(it)
55+
val chartModelProducer = remember { ChartEntryModelProducer(entries) }
7956

80-
chart.axisLeft.textColor = contentColor
81-
chart.axisRight.setDrawLabels(false)
82-
chart.xAxis.textColor = contentColor
83-
chart.description.isEnabled = false
84-
chart.legend.isEnabled = false
85-
86-
chart.setTouchEnabled(true)
87-
chart.isDragEnabled = true
88-
chart.setPinchZoom(false)
89-
chart.isScaleXEnabled = true
90-
chart.isScaleYEnabled = false
91-
chart.animateXY(0, 1000, EaseOutBounce)
92-
93-
chart.setOnChartValueSelectedListener(listener)
94-
95-
configure(chart)
57+
LaunchedEffect(entries) {
58+
chartModelProducer.setEntries(entries)
59+
}
9660

97-
chart.data = parsedData
98-
chart.invalidate()
99-
chart
100-
},
101-
update = {
102-
it.data = parsedData
103-
it.setOnChartValueSelectedListener(listener)
104-
it.invalidate()
105-
},
106-
modifier = modifier
61+
Chart(modifier = modifier,
62+
chart = lineChart(),
63+
chartModelProducer = chartModelProducer,
64+
bottomAxis = bottomAxis(
65+
axis = null,
66+
tickPosition = maxOf(data.maxOf { it.data.count() / 4 }, 1).let {
67+
HorizontalAxis.TickPosition.Center(it, it)
68+
},
69+
guideline = null,
70+
valueFormatter = { value, chartValues ->
71+
(chartValues.chartEntryModel.entries.firstOrNull()?.getOrNull(value.toInt()) as Entry?)?.xDisplayValue
72+
?: value.toString()
73+
},
74+
),
75+
endAxis = endAxis(
76+
axis = null,
77+
tick = null,
78+
guideline = null,
79+
valueFormatter = { value, _ ->
80+
if (value == 0f) "" else DecimalFormat("#.##;−#.##").format(value)
81+
},
82+
horizontalLabelPosition = VerticalAxis.HorizontalLabelPosition.Inside,
83+
maxLabelCount = 3
84+
),
85+
chartScrollSpec = rememberChartScrollSpec(isScrollEnabled = false),
86+
marker = rememberMarker()
10787
)
10888
}
10989

110-
private fun LineDataSet.configure(contentColor: Int? = null) {
111-
contentColor?.let { this.valueTextColor = it }
112-
this.mode = CUBIC_BEZIER
113-
this.cubicIntensity = 0.2f
114-
this.setDrawFilled(true)
115-
this.setDrawCircles(false)
116-
this.lineWidth = 1.8f
117-
this.highLightColor = Color.RED
118-
this.color = Color.WHITE
119-
this.fillColor = Color.WHITE
120-
this.fillAlpha = 100
121-
this.setDrawHorizontalHighlightIndicator(false)
122-
}
123-
124-
@Preview
90+
@Preview(showBackground = true)
12591
@Composable
12692
fun LineChartPreview() {
127-
LineChart(
128-
Modifier.fillMaxSize(),
129-
lineData = LineChartData(label = "label", data = listOf(Pair(0f, 0f), Pair(3f, 6f))),
130-
)
93+
val formatter = DateFormat.getDateInstance()
94+
PiHoleConnectTheme {
95+
LineChart(Modifier.fillMaxSize(), data = listOf(
96+
LineChartData(
97+
label = "label",
98+
data = listOf(1525546500 to 163, 1525547100 to 154, 1525547700 to 164),
99+
color = MaterialTheme.colorScheme.success
100+
), LineChartData(
101+
label = "label",
102+
data = listOf(1525546500 to 30, 1525547100 to 64, 1525547700 to 10),
103+
color = MaterialTheme.colorScheme.error
104+
)
105+
), xAxisFormatter = { formatter.format(it) })
106+
}
131107
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package com.tien.piholeconnect.ui.component
2+
3+
import android.graphics.Typeface
4+
import android.text.Spannable
5+
import android.text.style.ForegroundColorSpan
6+
import androidx.compose.material3.MaterialTheme
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.runtime.remember
9+
import androidx.compose.ui.graphics.Color
10+
import androidx.compose.ui.graphics.toArgb
11+
import androidx.compose.ui.unit.dp
12+
import com.patrykandpatrick.vico.compose.component.lineComponent
13+
import com.patrykandpatrick.vico.compose.component.overlayingComponent
14+
import com.patrykandpatrick.vico.compose.component.shapeComponent
15+
import com.patrykandpatrick.vico.compose.component.textComponent
16+
import com.patrykandpatrick.vico.compose.dimensions.dimensionsOf
17+
import com.patrykandpatrick.vico.core.chart.insets.Insets
18+
import com.patrykandpatrick.vico.core.chart.segment.SegmentProperties
19+
import com.patrykandpatrick.vico.core.chart.values.ChartValues
20+
import com.patrykandpatrick.vico.core.component.marker.MarkerComponent
21+
import com.patrykandpatrick.vico.core.component.shape.DashedShape
22+
import com.patrykandpatrick.vico.core.component.shape.ShapeComponent
23+
import com.patrykandpatrick.vico.core.component.shape.Shapes
24+
import com.patrykandpatrick.vico.core.component.shape.cornered.Corner
25+
import com.patrykandpatrick.vico.core.component.shape.cornered.MarkerCorneredShape
26+
import com.patrykandpatrick.vico.core.context.MeasureContext
27+
import com.patrykandpatrick.vico.core.extension.appendCompat
28+
import com.patrykandpatrick.vico.core.extension.copyColor
29+
import com.patrykandpatrick.vico.core.extension.transformToSpannable
30+
import com.patrykandpatrick.vico.core.marker.Marker
31+
import com.patrykandpatrick.vico.core.marker.MarkerLabelFormatter
32+
import com.tien.piholeconnect.model.Entry
33+
import java.text.DecimalFormat
34+
35+
@Composable
36+
fun rememberMarker(): Marker {
37+
val labelBackgroundColor = MaterialTheme.colorScheme.surface
38+
val labelBackground = remember(labelBackgroundColor) {
39+
ShapeComponent(labelBackgroundShape, labelBackgroundColor.toArgb()).setShadow(
40+
radius = LABEL_BACKGROUND_SHADOW_RADIUS,
41+
dy = LABEL_BACKGROUND_SHADOW_DY,
42+
applyElevationOverlay = true,
43+
)
44+
}
45+
val label = textComponent(
46+
color = MaterialTheme.colorScheme.onSurface,
47+
background = labelBackground,
48+
lineCount = LABEL_LINE_COUNT,
49+
padding = labelPadding,
50+
typeface = Typeface.MONOSPACE,
51+
)
52+
val indicatorInnerComponent =
53+
shapeComponent(Shapes.pillShape, MaterialTheme.colorScheme.surface)
54+
val indicatorCenterComponent = shapeComponent(Shapes.pillShape, Color.White)
55+
val indicatorOuterComponent = shapeComponent(Shapes.pillShape, Color.White)
56+
val indicator = overlayingComponent(
57+
outer = indicatorOuterComponent,
58+
inner = overlayingComponent(
59+
outer = indicatorCenterComponent,
60+
inner = indicatorInnerComponent,
61+
innerPaddingAll = indicatorInnerAndCenterComponentPaddingValue,
62+
),
63+
innerPaddingAll = indicatorCenterAndOuterComponentPaddingValue,
64+
)
65+
val guideline = lineComponent(
66+
MaterialTheme.colorScheme.onSurface.copy(GUIDELINE_ALPHA),
67+
guidelineThickness,
68+
guidelineShape,
69+
)
70+
return remember(label, indicator, guideline) {
71+
object : MarkerComponent(label, indicator, guideline) {
72+
init {
73+
indicatorSizeDp = INDICATOR_SIZE_DP
74+
onApplyEntryColor = { entryColor ->
75+
indicatorOuterComponent.color =
76+
entryColor.copyColor(INDICATOR_OUTER_COMPONENT_ALPHA)
77+
with(indicatorCenterComponent) {
78+
color = entryColor
79+
setShadow(
80+
radius = INDICATOR_CENTER_COMPONENT_SHADOW_RADIUS, color = entryColor
81+
)
82+
}
83+
}
84+
}
85+
86+
override fun getInsets(
87+
context: MeasureContext, outInsets: Insets, segmentProperties: SegmentProperties
88+
) = with(context) {
89+
outInsets.top =
90+
label.getHeight(context) + labelBackgroundShape.tickSizeDp.pixels + LABEL_BACKGROUND_SHADOW_RADIUS.pixels * SHADOW_RADIUS_MULTIPLIER - LABEL_BACKGROUND_SHADOW_DY.pixels
91+
}
92+
}.apply {
93+
labelFormatter = object : MarkerLabelFormatter {
94+
95+
private val PATTERN = DecimalFormat("#.##;−#.##")
96+
97+
override fun getLabel(
98+
markedEntries: List<Marker.EntryModel>,
99+
chartValues: ChartValues,
100+
): CharSequence = markedEntries.transformToSpannable(
101+
prefix = when (val entry = markedEntries.firstOrNull()?.entry) {
102+
is Entry -> entry.xDisplayValue ?: PATTERN.format(entry.x)
103+
null -> ""
104+
else -> PATTERN.format(entry.x)
105+
} + if (markedEntries.size > 1) " (" else " ",
106+
postfix = if (markedEntries.size > 1) ")" else "",
107+
separator = "; ",
108+
) { model ->
109+
appendCompat(
110+
when (val entry = model.entry) {
111+
is Entry -> PATTERN.format(model.entry.y) + (entry.yLabel?.let { " $it" }
112+
?: "")
113+
114+
else -> PATTERN.format(model.entry.y)
115+
},
116+
ForegroundColorSpan(model.color),
117+
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
118+
)
119+
}
120+
}
121+
}
122+
}
123+
}
124+
125+
const val LABEL_BACKGROUND_SHADOW_RADIUS = 4f
126+
const val LABEL_BACKGROUND_SHADOW_DY = 2f
127+
const val LABEL_LINE_COUNT = 1
128+
const val GUIDELINE_ALPHA = .2f
129+
const val INDICATOR_SIZE_DP = 36f
130+
const val INDICATOR_OUTER_COMPONENT_ALPHA = 32
131+
const val INDICATOR_CENTER_COMPONENT_SHADOW_RADIUS = 12f
132+
const val GUIDELINE_DASH_LENGTH_DP = 8f
133+
const val GUIDELINE_GAP_LENGTH_DP = 4f
134+
const val SHADOW_RADIUS_MULTIPLIER = 1.3f
135+
136+
val labelBackgroundShape = MarkerCorneredShape(Corner.FullyRounded)
137+
val labelHorizontalPaddingValue = 8.dp
138+
val labelVerticalPaddingValue = 4.dp
139+
val labelPadding = dimensionsOf(labelHorizontalPaddingValue, labelVerticalPaddingValue)
140+
val indicatorInnerAndCenterComponentPaddingValue = 5.dp
141+
val indicatorCenterAndOuterComponentPaddingValue = 10.dp
142+
val guidelineThickness = 2.dp
143+
val guidelineShape =
144+
DashedShape(Shapes.pillShape, GUIDELINE_DASH_LENGTH_DP, GUIDELINE_GAP_LENGTH_DP)

0 commit comments

Comments
 (0)